Sessions, JWT, CORS, Load Balancers and Proxies
Where identity lives (cookies, sessions, JWT), what CORS really restricts, and how L4 and L7 balancers and proxies sit in front of servers.
- 3 partsJWT: header, payload, signature
- ~15 minTypical short access-token lifetime
- L4 / L7Connection routing versus request routing
Show what to say
“A session keeps state on the server behind an HttpOnly, Secure, SameSite cookie, so it can be revoked instantly. A JWT is signed and stateless, so any service can verify it, but it lives until it expires. CORS is the browser asking the server's permission for cross-origin reads, with a preflight for non-simple requests. L4 balances connections, L7 reads HTTP and can route and terminate TLS.”
Trade-offs
- Sessions vs JWT: sessions revoke at once but need a lookup per request; a JWT needs no lookup but cannot be revoked before it expires.
- L4 vs L7 balancer: L4 is fast and protocol-agnostic; L7 routes by path or header and terminates TLS but uses more CPU.
- Sticky sessions vs shared store: stickiness is easy but loads unevenly and loses sessions on failure; a shared store keeps servers stateless.
Bring it up before they ask
CORS is enforced by browsers only, so say it protects users and that the API still needs its own authentication.
HTTP is , so identity travels in a cookie (an ID for a server-side session) or a signed . is the browser deciding whether a page may read a cross-origin reply, and balancers and proxies route by IP and port (L4) or by what the request says (L7).
Cookies and sessions
The server cannot remember you between requests, so it hands the browser a cookie to send back every time.
Read more · 124 words
In a session, the cookie is only a random ID and the data stays on the server, often in .
| Attribute | What it does |
|---|---|
| HttpOnly | JavaScript cannot read the cookie, so an XSS bug cannot steal it |
| Secure | Sent only over HTTPS |
| SameSite=Lax | Not sent on cross-site requests, which blocks most CSRF |
| Max-Age | When the browser drops it |
- Client
- Service
- Cache
Text description
- Browser → App server: POST /login (email, password)
- App server → Session store: SET sess:9f3a = user 42, TTL 30 min
- App server replies to Browser: Set-Cookie: sid=9f3a; HttpOnly; Secure; SameSite=Lax
- Browser → App server: GET /account Cookie: sid=9f3a
- App server → Session store: GET sess:9f3a
- Session store replies to App server: user 42
- App server replies to Browser: 200 OK (account page)
JWT versus server sessions
A is a self-contained token: header, payload of claims (who, until when) and signature.
Read more · 65 words
Any server with the key can verify it with no lookup, which suits many services sharing one login.
The payload is signed, not encrypted, so keep secrets out. The cost is revocation: until exp passes the token is valid, so banning someone needs a deny-list or a lifetime of minutes plus a refresh token. A session is the opposite: one lookup per request, ended instantly.
import { createHmac, timingSafeEqual } from 'node:crypto';
// A JWT is three base64url parts: header.payload.signature. The payload is only SIGNED, not encrypted:
// anyone can read it, but nobody can change it without the secret.
const encode = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
const decode = (part) => JSON.parse(Buffer.from(part, 'base64url').toString());
const sign = (data, secret) => createHmac('sha256', secret).update(data).digest('base64url');
export function issue(claims, secret, { ttlSeconds = 900, now = Date.now() } = {}) {
const iat = Math.floor(now / 1000);
const header = encode({ alg: 'HS256', typ: 'JWT' });
const payload = encode({ ...claims, iat, exp: iat + ttlSeconds });
return `${header}.${payload}.${sign(`${header}.${payload}`, secret)}`;
}
export function verify(token, secret, { now = Date.now() } = {}) {
const [header, payload, signature, extra] = token.split('.');
if (!header || !payload || !signature || extra !== undefined) throw new Error('malformed');
if (decode(header).alg !== 'HS256') throw new Error('unexpected algorithm'); // never let the token choose its own check
const expected = Buffer.from(sign(`${header}.${payload}`, secret));
const given = Buffer.from(signature);
if (expected.length !== given.length || !timingSafeEqual(expected, given)) throw new Error('bad signature');
const claims = decode(payload);
if (claims.exp * 1000 <= now) throw new Error('expired');
return claims;
}CORS
Browsers apply the same-origin policy: a script cannot read replies from another origin (scheme, host, port).
Read more · 91 words
is how a server opts in, through Access-Control-Allow-* headers. For a PUT, a JSON body or an Authorization header, the browser first sends an OPTIONS preflight and continues only if allowed.
CORS is enforced by the browser only. curl and other servers ignore it, so it protects users, not your API. With cookies the server must echo a specific origin and send Allow-Credentials; * is not allowed.
- Client
- Service
Text description
- app.example.com → api.example.com: GET /items Origin: app.example.com
- api.example.com replies to app.example.com: 200 + Access-Control-Allow-Origin: app.example.com
// CORS is the SERVER telling the BROWSER which other sites' scripts may read its responses.
// A preflight is an OPTIONS request the browser sends first for anything beyond a plain GET/POST form.
export function cors(request, config) {
const { method, origin, requestMethod, requestHeaders = [] } = request;
const { allowed, methods = ['GET', 'POST'], headers = ['content-type'], credentials = false, maxAge = 600 } = config;
const preflight = method === 'OPTIONS' && requestMethod !== undefined;
if (!origin || !allowed.includes(origin)) return { status: preflight ? 403 : 200, headers: {} }; // no CORS headers: the browser hides the reply
// The origin is echoed back, never '*', because credentials are not allowed with a wildcard.
const base = { 'access-control-allow-origin': origin, vary: 'Origin' };
if (credentials) base['access-control-allow-credentials'] = 'true';
if (!preflight) return { status: 200, headers: base };
const okMethod = methods.includes(requestMethod);
const okHeaders = requestHeaders.every((h) => headers.includes(h.toLowerCase()));
if (!okMethod || !okHeaders) return { status: 403, headers: {} };
return { status: 204, headers: { ...base, 'access-control-allow-methods': methods.join(', '), 'access-control-allow-headers': headers.join(', '), 'access-control-max-age': String(maxAge) } };
}Load balancing: L4 versus L7
An L4 balancer picks a backend from IP addresses and ports and forwards packets without reading them: fast and protocol-agnostic.
Read more · 58 words
An L7 balancer terminates the connection and reads the HTTP request, so it can route by path or header, retry, set cookies and terminate , at the cost of CPU.
Algorithms and health checks are in the HLD path.
Proxies: forward and reverse
A forward proxy acts for clients, such as a corporate gateway filtering outbound traffic.
Read more · 203 words
A acts for servers: clients talk to it as if it were the site, and it terminates , , and routes.
nginx, Envoy and HAProxy are reverse proxies; a is one spread across the world; an adds auth and quotas. The proxy passes the real client address in X-Forwarded-For; trust it only when your own proxy set it.
Tap any box to see what it is and what it talks to.
- Client
- Load balancer
- Service
Text description
- GET https://example.com/api/orders arrives over TLS
- The proxy decrypts it, reads the path and forwards to the API pool with X-Forwarded-For: client IP.
- The API pool answers the proxy.
- The proxy sends the reply back over the original connection.
- GET /index.html arrives next.
- Different path, different pool: forwarded to the web servers.
Sticky sessions and where state lives
pin a user to one backend (by cookie or IP hash) so in-memory state keeps working.
Read more · 117 words
The costs: load spreads unevenly, and when that server dies the sessions on it die too.
The cleaner design is servers with sessions in a shared store such as , or in a . Then any server can answer any request, and a deploy or a crash costs nothing.
TLS termination and trust boundaries
Terminating at the proxy keeps certificates in one place and offloads the crypto, but traffic behind it is plain unless you re-encrypt it.
Read more · 38 words
Inside a shared network use TLS or mutual TLS (mTLS) between services so a compromised host cannot read the rest. The proxy tells the app which scheme was used (X-Forwarded-Proto) and adds Strict-Transport-Security so browsers insist on HTTPS.
Auth mistakes to avoid
Read more · 144 words
- Trusting the token's
alg. Always verify with the algorithm you chose, never the one the token names. - Trusting forwarded headers from the open internet instead of only from your proxy.
- is delegation, not login. OAuth 2.0 lets an app act for you at another service; OpenID Connect adds identity on top.
Follow-up questions
Sessions or JWT?
Sessions keep state on the server: easy to revoke, one lookup per request. are and verified by any service holding the key, but hard to revoke before they expire, so keep them short-lived.
What is CORS and what is a preflight?
Browsers block scripts from reading cross-origin replies unless the server opts in with Access-Control headers. For non-simple requests the browser first sends an OPTIONS preflight to check the method and headers are allowed.
What is the difference between L4 and L7 load balancing?
L4 forwards connections by IP and port without reading the payload, so it is fast. L7 reads the HTTP request, so it can route by path or header, terminate and retry, using more CPU.
Forward proxy versus reverse proxy?
A forward proxy represents clients to the internet. A represents servers to clients, handling TLS, , routing and in front of them.
Check yourself
Question 1 of 7Score 2 out of 3 or better to mark this page complete.