Would you use server-side sessions or JWTs for authenticating your API, and why?
Default to server-side sessions in an opaque HttpOnly cookie, because revocation is a row delete and the browser cannot read the credential. Choose JWTs only when a resource server must validate without calling the issuer, keep them short-lived, and pin the algorithm, issuer, audience and expiry on every verification.
What the interviewer is scoring
- Whether you treat revocation as the deciding constraint rather than an afterthought
- Whether you notice that a JWT denylist or introspection call reintroduces the state JWTs were meant to remove
- Whether you can separate the XSS risk from the CSRF risk instead of conflating them into "cookies are unsafe"
- Whether you name concrete validation failures such as algorithm confusion and an unverified signature
- Whether you pick a default and defend it, rather than listing both options neutrally
Answer
The default, and the condition that changes it
For a first-party web or mobile application talking to your own API, server-side sessions are the better default. The client holds an opaque, high-entropy identifier; the server holds the actual state. Every request costs one lookup in a store you already operate, and in exchange you get immediate revocation, cheap privilege changes, and a credential that leaks nothing if it is captured in a log.
The condition that justifies a JWT is a resource server that must validate a credential without a synchronous call to whatever issued it. That is the real problem JWTs solve: cross-service or cross-organisation verification via a signature, so a service holding only the issuer's public key can decide whether to trust the claims. If every request already terminates at infrastructure that can reach your session store in a millisecond, you are paying the cost of a stateless design without collecting its benefit.
The revocation problem, stated honestly
A signed token is valid until it expires, because validation is a local computation over the token's own bytes. There is no point in the design where the issuer gets a vote. If an account is disabled, a role is downgraded, or a token is stolen, the token keeps working until exp passes.
Every mitigation walks back the statelessness. A jti denylist means each verification consults a shared store, which is a session lookup wearing a different hat, with the added quirk that entries must be retained until natural expiry. Token introspection (RFC 7662) is an explicit network call to the authorisation server, which is even more coupling than a session read. Short lifetimes with a long-lived refresh token narrow the window rather than closing it, and the refresh token is itself stateful, because rotation with reuse detection requires the server to remember which refresh tokens it has issued and retired. RFC 7009 defines a revocation endpoint, but it revokes at the authorisation server; a resource server validating locally will not notice.
So the honest framing is: JWTs move you from "revocation is a delete" to "revocation is eventual, bounded by token lifetime". That is acceptable for a five-minute access token in front of an audit-logged service. It is not acceptable for a token you minted with a thirty-day expiry to avoid writing a refresh flow.
Cookie versus localStorage
Put the credential in a cookie with HttpOnly, Secure, and SameSite. HttpOnly removes the credential from JavaScript's reach, so an XSS payload cannot exfiltrate it and replay it later from the attacker's own machine. That distinction matters: XSS is still catastrophic with cookies, because the injected script can issue authenticated requests from the victim's browser, but the damage is confined to the session's lifetime in that browser rather than becoming a portable, long-lived stolen credential.
localStorage is the opposite trade. It is readable by any script on the origin, including every transitive dependency in your bundle, and it has no equivalent of HttpOnly. Teams choose it to avoid CSRF, which is a real property: because the token is attached by application code rather than automatically by the browser, a cross-site request carries no credential. But CSRF has cheap, well-understood defences, whereas token theft via XSS does not. Trading a solved problem for an unsolved one is the wrong direction.
With cookies you owe a CSRF defence: SameSite=Lax (which Chrome applies as the default when the attribute is absent) blocks cookies on cross-site subrequests and on cross-site POST navigations, and for state-changing endpoints you back it with a synchroniser token or a strict Origin header check. Cookie semantics come from RFC 6265; the SameSite attribute was defined in later work on that specification rather than in 6265 itself.
The specific JWT validation pitfalls
Algorithm confusion is the classic one. The alg header is attacker-controlled input, so a library API that takes a key and reads the algorithm from the token will happily verify an HS256 token whose MAC was computed using your published RSA public key as the shared secret. The related case is alg: none, the unsecured JWT permitted by the JWS specification, which some libraries have historically accepted as valid. The fix is to pin the accepted algorithms at the call site and never let the token choose.
The second pitfall is decoding without verifying. Most libraries expose both a decode and a verify function, and the decode path parses claims without touching the signature. Reading a userId out of a decoded-but-unverified token is an authentication bypass, not a shortcut.
The third is skipping claim checks. RFC 7519 makes exp, aud, and iss optional, and libraries validate them only when you say what you expect. Without an aud check, a token minted for a low-privilege service is replayable against a high-privilege one. Without iss plus key resolution scoped to that issuer, a multi-tenant deployment can accept a token signed by the wrong tenant's key. RFC 8725 collects these into concrete guidance and is the document to cite.
// Wrong: the token's own header decides the algorithm, and nothing
// constrains who the token was for.
jwt.verify(token, publicKey);
// Right: algorithm pinned, issuer and audience asserted, and the
// key resolved by `kid` from the issuer's JWKS. Resolving the key
// through a callback commits you to the asynchronous form -
// jsonwebtoken throws if a key function is passed without one.
jwt.verify(
token,
getKey,
{
algorithms: ['RS256'], // reject HS256 and 'none' outright
issuer: 'https://auth.example.com',
audience: 'https://api.example.com/orders',
},
(err, claims) => {
if (err) return reject(err);
resolve(claims);
},
);
The trap
The trap is answering on the axis of scalability. Candidates say sessions "don't scale" because they require server state, and JWTs are "stateless, therefore horizontally scalable". A shared Redis or database session store scales perfectly well horizontally; the thing that does not scale is sticky in-memory sessions on individual application servers, which is an implementation choice, not a property of sessions. Meanwhile the stateless design almost always acquires state again the moment someone asks for logout-everywhere or immediate deactivation. Answer on the axis of revocation latency and trust boundaries, and the trade-off becomes something you can actually reason about.
Sessions make revocation a delete and statelessness the thing you give up; JWTs make statelessness free and revocation the thing you give up. Pick the one whose loss your system can survive, and if you pick JWTs, pin the algorithm and assert
iss,aud, andexpon every single verification.
Likely follow-ups
- How would you implement logout-everywhere for a system that has already committed to stateless access tokens?
- Why does refresh-token rotation with reuse detection help, and what does it cost you?
- Where would you store the signing key, and how do you rotate it without invalidating every live token?
- How does this design change when the token has to cross a trust boundary into a third party's service?
Related questions
- Design the account-information API a third party will call on behalf of your customers. Where does consent live, and what does it constrain?hardAlso on oauth26 min
- Why do we hash passwords instead of encrypting them, and what makes a password hash different from SHA-256?mediumAlso on authentication5 min
- How does your order placement change on a venue that allocates pro rata within a price level instead of first in, first out?hardSame kind of round: concept6 min
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardSame kind of round: concept7 min
- How do you test a Node service, and what do you refuse to mock?mediumSame kind of round: concept5 min
- How does Node load your modules, and how would you make a Node service use more than one core?mediumSame kind of round: concept6 min
- Angular, Vue and React are answers to the same problems. Compare how each handles change detection, reactivity and dependency injection.hardSame kind of round: concept5 min
- How do you turn what the business tells you into a domain model that holds up once the exceptions arrive?hardSame kind of round: design7 min