Loading...
Loading...
Where do you store an access token? The trade-off answer interviewers want.
Module 13 · Authentication & Authorization
Sessions vs JWT, OAuth 2.0 and OIDC flows, access and refresh tokens, silent refresh, rotation, logout and revocation, RBAC vs ABAC, and route protection.
Cookie sessions vs stateless JWT, OAuth 2.0 Authorization Code with PKCE, OIDC, access and refresh token lifetimes, silent and rotating refresh, single-flight 401 handling, logout and revocation, RBAC vs ABAC, and route protection.
"Where would you store an access token?" is one of the most common frontend interview questions, and the expected answer is a trade-off discussion, not a one-liner.
This module covers the full identity picture: cookie sessions vs stateless JWT, the OAuth 2.0 Authorization Code flow with PKCE, OIDC identity tokens, and how SSO works from the browser's point of view.
You will learn token lifetimes, silent and rotating refresh, handling 401s with a single-flight refresh queue, revocation and logout across tabs, and why httpOnly + SameSite cookies beat localStorage under XSS.
It closes with authorization: authentication versus authorization, RBAC versus ABAC, protecting routes in the App Router with middleware, and why client-side guards are UX rather than security.
Phase 7 (Week 7) of the interview roadmap. Senior Track spans modules 1–19; the Staff Track starts at module 20.
"Where would you store an access token?" is asked in almost every frontend loop, and a one-line answer fails it. Strong candidates compare httpOnly cookies against memory and localStorage under XSS and CSRF, then explain refresh, revocation, and what the server must still enforce.
Who you are, versus what you are allowed to do.
Authentication establishes identity. Authorization decides permissions. They are separate systems that fail in separate ways, and conflating them is the fastest way to lose a security question.
| Authentication | Authorization | |
|---|---|---|
| Question | Who are you? | What may you do? |
| Artifact | Session or token | Roles, scopes, policies |
| Failure code | 401 Unauthorized | 403 Forbidden |
| Frontend role | Obtain and attach credentials | Hide disallowed UI |
| Source of truth | Auth server | Resource server |
Client-side authorization is user experience, never security. Hiding an admin button stops confusion, not an attacker with DevTools. The server re-checks every decision on every request.
Stateful and revocable, or stateless and fast.
| Aspect | Cookie session | Stateless JWT |
|---|---|---|
| Server state | Session store lookup | None: signature verify |
| Revocation | Immediate: delete the record | Hard until expiry |
| Scaling | Needs shared store (Redis) | Scales trivially |
| Payload size | Tiny id | Larger, sent on every call |
| Cross-domain APIs | Awkward | Natural |
| Best for | First-party web apps | APIs, mobile, service-to-service |
Short-lived JWT access tokens for API calls, plus a stateful, revocable refresh token held server-side. You get stateless verification on the hot path and real revocation where it matters.
Delegated authorization: let a user grant access without sharing a password.
Authorization Code + PKCE 1. App generates code_verifier (random) and code_challenge = SHA256(code_verifier) 2. Redirect → /authorize?client_id&redirect_uri&state&code_challenge 3. User authenticates and consents at the auth server 4. Redirect back → /callback?code=...&state=... 5. App verifies state, then POSTs code + code_verifier to /token 6. Auth server returns access_token (+ refresh_token)
OAuth answers 'may this app act for you'. OIDC answers 'who are you'.
Do not send the id_token to your APIs as a bearer credential. It proves identity to your client, not authorization to a resource server. Send the access token.
Short access tokens shrink the blast radius; refresh tokens keep users signed in.
| Token | Typical lifetime | Sent to | If stolen |
|---|---|---|---|
| Access | 5–15 minutes | Resource APIs | Expires quickly |
| Refresh | Days to weeks | Auth server only | Serious: rotate and revoke |
| ID | Minutes | Nobody: read locally | Identity disclosure |
The most-asked question in this chapter. Answer with a threat model.
| Location | XSS exposure | CSRF exposure | Survives reload |
|---|---|---|---|
| httpOnly cookie | Not readable by JS | Needs SameSite + CSRF token | Yes |
| In-memory variable | Only while running | None | No |
| localStorage | Fully readable | None | Yes |
| sessionStorage | Fully readable | None | Per tab |
The default recommendation is an httpOnly, Secure, SameSite cookie: script cannot read it, so XSS cannot exfiltrate it. The cost is that the browser attaches it automatically, so you must defend against CSRF.
When cookies are impossible (a third-party API on another origin), hold the access token in memory only and rely on a refresh call after reload. localStorage is the weakest option: any injected script reads it instantly.
Set-Cookie: session=<opaque>; HttpOnly; // invisible to document.cookie Secure; // HTTPS only SameSite=Lax; // blocks most cross-site sends Path=/; Max-Age=1209600
“It depends on the threat you are optimizing against. XSS beats every storage choice, so cookies plus a strong CSP are my default; memory-only is the fallback for cross-origin APIs; localStorage only for low-value, short-lived tokens.”
Renew silently, rotate every time, and detect reuse.
With rotation plus reuse detection, a stolen refresh token becomes self-revealing: as soon as either the attacker or the real user refreshes, the other one presents a used token and the whole chain is revoked.
Six parallel requests expire together. You should refresh once, not six times.
let refreshing: Promise<void> | null = null; async function apiFetch(input: RequestInfo, init?: RequestInit) { let res = await fetch(input, { ...init, credentials: "include" }); if (res.status !== 401) return res; // Every concurrent 401 awaits the same in-flight refresh. refreshing ??= refreshSession().finally(() => { refreshing = null; }); try { await refreshing; } catch { redirectToLogin(); throw new Error("Session expired"); } return fetch(input, { ...init, credentials: "include" }); }
Clearing local state is not logging out.
const channel = new BroadcastChannel("auth"); export function logoutEverywhere() { channel.postMessage({ type: "logout" }); } channel.onmessage = (e) => { if (e.data.type === "logout") { queryClient.clear(); location.replace("/login"); } };
Roles are simple. Attributes are expressive. Most products end up mixing them.
| RBAC | ABAC | |
|---|---|---|
| Decides on | Role assignment | Attributes and context |
| Example | Admin may delete users | Owner may edit before publish |
| Strength | Easy to reason about and audit | Fine-grained, data-aware |
| Weakness | Role explosion at scale | Harder to test and explain |
| Frontend use | Toggle nav and actions | Usually resolved server-side |
Middleware at the edge, checks at the data layer, guards for UX.
// middleware.ts — runs before the route renders. export function middleware(request: NextRequest) { const session = request.cookies.get("session"); if (!session) { const url = new URL("/login", request.url); url.searchParams.set("next", request.nextUrl.pathname); return NextResponse.redirect(url); } return NextResponse.next(); } export const config = { matcher: ["/app/:path*"] };
How to answer the token storage question and everything after it.
Authentication and authorization interview questions.