JWT (Access Tokens & Refresh Tokens)
JWT (JSON Web Token) is a compact, signed token format. Many systems use it for API authentication: the client sends a token, the server verifies it, then decides what the caller can do.
In the wild: auth breaks most often at the seams — redirects, token storage, callback handling — not in the login form itself.
Mental model
- Header: says which algorithm is used (e.g., HS256, RS256) and may include a key identifier (
kid). - Payload (claims): identity and authorization context (e.g.,
sub, roles/scopes,iss,aud,exp). - Signature: cryptographic proof the token was issued by a trusted issuer and not modified.
- Access token: short-lived token to call APIs.
- Refresh token: longer-lived credential to obtain new access tokens (must be strongly protected; ideally stored server-side or bound to device).
JWT verification rules (non-negotiables)
- Never “decode” and trust: decoding is parsing, not verification. The server must verify the signature using the expected keys.
- Restrict algorithms: accept only the intended algorithm(s) for that issuer/service.
- Validate critical claims:
iss(issuer),aud(audience),exp(expiry),nbf(not-before), andsub(subject) semantics. - Enforce token lifetime: short access TTL; consider a max token age even if
expis far out. - Key management: rotate keys safely; handle
kidcarefully; log and monitor key usage. - Minimize trust in claims: claims are inputs. Use them to identify the caller, but enforce permissions via server-side checks.
Code: vulnerable vs defensive patterns (Node.js)
Vulnerable pattern: decoding without verification
// ❌ Decoding just parses JSON. It does NOT prove authenticity.
import jwt from "jsonwebtoken";
app.get("/me", (req, res) => {
const token = req.headers.authorization?.replace("Bearer ", "");
const data = jwt.decode(token); // not verified!
// Any decision made from "data" can be forged if signature isn't verified.
res.json({ userId: data?.sub, role: data?.role });
}); Defensive pattern: verify signature + strict claims + explicit algorithms
import jwt from "jsonwebtoken";
const VERIFY_OPTS = {
algorithms: ["RS256"], // ✅ accept only what you use
issuer: "https://auth.example", // ✅ expected issuer
audience: "api://my-service", // ✅ expected audience
clockTolerance: 5, // ✅ handle small clock skew
};
function verifyAccessToken(token) {
// For RS256, verify with the issuer's public key.
return jwt.verify(token, process.env.JWT_PUBLIC_KEY, VERIFY_OPTS);
}
app.get("/me", (req, res) => {
try {
const raw = String(req.headers.authorization || "");
const token = raw.startsWith("Bearer ") ? raw.slice(7) : "";
const claims = verifyAccessToken(token);
// ✅ Authorization should be server-side; claims can carry scopes, but policy lives on the server.
res.json({ ok: true, sub: claims.sub, scopes: claims.scope });
} catch {
res.status(401).json({ ok: false, error: "Unauthorized" });
}
}); Safe validation (defensive verification)
- Verification: confirm the server uses signature verification (not decode) and rejects invalid signatures.
- Algorithm policy: confirm only the intended algorithm is accepted (no “accept anything the header says”).
- Claims: confirm strict checks of
iss,aud,exp,nbf; define acceptable values per environment/tenant. - Subject semantics: confirm
submaps to an existing user/service and is not used as the only authorization gate. - Key sourcing: confirm keys come from trusted config/JWKS endpoint with caching, pinning/allowlisting, and sane timeouts.
- Lifecycle: confirm short access TTL, refresh rotation, and a revocation strategy (especially after logout/password reset).
- Logging: confirm tokens are never logged; log only token metadata (kid, issuer, jti) if needed.
JWT attack surface map (what can go wrong)
JWT issues usually fall into one of four buckets. In interviews, describe them as mis-validation, key confusion, claim misuse, and lifecycle gaps.
- Mis-validation: decode instead of verify; accepting unsigned/weakly verified tokens; wrong key used.
- Algorithm & key confusion: accepting unexpected algorithms or mixing symmetric/asymmetric validation paths incorrectly.
- Claim misconfiguration: missing
iss/audchecks; weak expiry handling; trustingsubor roles blindly. - Lifecycle gaps: long TTLs, no refresh rotation, no revocation, token leakage via logs/URLs/storage.
1) Mis-validation patterns (high risk)
A) Decoding without verification (decode() / parsing only)
- What it means: the app reads claims but never proves authenticity.
- Impact: identity and authorization context becomes attacker-controlled input.
- Fix: enforce signature verification via a single shared middleware; deny by default.
Vulnerable code (Node.js)
import jwt from "jsonwebtoken";
app.get("/admin", (req, res) => {
const token = String(req.headers.authorization || "").replace("Bearer ", "");
const claims = jwt.decode(token); // ❌ not verified
if (claims?.role === "admin") return res.send("admin panel"); // ❌ authorization from untrusted data
return res.status(403).send("forbidden");
}); Defensive code (Node.js)
import jwt from "jsonwebtoken";
const VERIFY = {
algorithms: ["RS256"],
issuer: "https://auth.example",
audience: "api://my-service",
clockTolerance: 5,
};
function requireAuth(req, res, next) {
try {
const raw = String(req.headers.authorization || "");
const token = raw.startsWith("Bearer ") ? raw.slice(7) : "";
req.jwt = jwt.verify(token, process.env.JWT_PUBLIC_KEY, VERIFY); // ✅ verified
return next();
} catch {
return res.status(401).send("unauthorized");
}
}
app.get("/admin", requireAuth, (req, res) => {
// ✅ Authorization should be server-side policy; claims are context, not the policy.
const isAdmin = req.jwt?.scope?.includes("admin:read");
return isAdmin ? res.send("admin panel") : res.status(403).send("forbidden");
}); B) Wrong verification context (right crypto, wrong rules)
- What it means: signature is verified, but against the wrong key, wrong issuer/audience, or wrong tenant/environment.
- Impact: “valid” tokens from another context can be accepted.
- Fix: strict
iss/audper service + environment + tenant; avoid “accept any issuer/audience”.
Vulnerable code: signature only (claims not pinned)
// ❌ Missing issuer/audience pinning makes token confusion more likely.
const claims = jwt.verify(token, process.env.JWT_PUBLIC_KEY, { algorithms: ["RS256"] }); Defensive code: pin issuer & audience
const claims = jwt.verify(token, process.env.JWT_PUBLIC_KEY, {
algorithms: ["RS256"],
issuer: "https://auth.example",
audience: "api://my-service",
clockTolerance: 5,
});
2) Algorithm policy pitfalls (conceptual)
JWT supports multiple algorithms. Security breaks when the server lets the token decide how it should be verified instead of enforcing a server-side policy.
A) “None” / unsigned token acceptance
- What it is: accepting unsigned tokens or treating missing signatures as valid.
- Why it happens: legacy modes, custom verification logic, or misconfigured libraries.
- Defense: disallow unsigned tokens; restrict
algto an allowlist; verify with a vetted library.
Vulnerable code: trusting decoded header to decide verification
// ❌ Never use the header to decide whether to verify.
const parsed = jwt.decode(token, { complete: true });
if (parsed?.header?.alg === "none") return res.status(200).send("ok"); Defensive code: pin algorithms, always verify
const claims = jwt.verify(token, process.env.JWT_PUBLIC_KEY, {
algorithms: ["RS256"], // ✅ pinned
issuer: "https://auth.example",
audience: "api://my-service",
}); B) Symmetric vs asymmetric confusion (HS* vs RS/ES*)
- What it is: mixing validation paths so a token is verified under an unexpected algorithm/key type.
- Why it happens: accepting multiple algorithms at once or reusing key material incorrectly across algorithms.
- Defense: enforce exactly one algorithm per issuer; keep key material type-separated; never reuse public keys as HMAC secrets.
Vulnerable code: “compatibility” algorithms list
// ❌ Accepting both HS and RS often leads to confusion bugs.
const claims = jwt.verify(token, key, { algorithms: ["HS256", "RS256"] }); Defensive code: issuer → policy map (explicit)
const POLICIES = {
"https://auth.example": {
algorithms: ["RS256"],
key: process.env.JWT_PUBLIC_KEY,
audience: "api://my-service",
},
// Add other issuers explicitly (no wildcards)
};
function verifyWithPolicy(token, issuer) {
const p = POLICIES[issuer];
if (!p) throw new Error("unknown issuer");
return jwt.verify(token, p.key, {
algorithms: p.algorithms,
issuer,
audience: p.audience,
clockTolerance: 5,
});
} 3) Weak symmetric secrets (HS256/HS512) — risk and defense
With HMAC-based JWTs (HS*), the same secret both signs and verifies. If the secret is weak, leaked, shared too widely, or reused, the system becomes fragile.
- Root causes: low-entropy secrets, secrets committed to repos, shared across environments, or used by too many services.
- Impact: if the secret is exposed, A tester can mint valid-looking tokens.
- Defense: prefer RS256/ES256 for multi-service validation; store secrets in a vault; rotate regularly; scope keys per env/service.
Defensive code: guardrails for HS* (Node.js)
import jwt from "jsonwebtoken";
const secret = process.env.JWT_HS_SECRET || "";
// ✅ Guardrail: refuse to run with weak/missing secrets.
if (secret.length < 32) throw new Error("JWT_HS_SECRET must be high entropy and vault-managed");
function verifyHS(token) {
return jwt.verify(token, secret, {
algorithms: ["HS256"], // ✅ pinned
issuer: "https://auth.example", // ✅ pinned
audience: "api://my-service", // ✅ pinned
clockTolerance: 5,
maxAge: "15m", // ✅ optional max age
});
} 4) Claim misconfiguration (where “verified” tokens still fail security)
A) iss (issuer) & aud (audience)
- Common bug: signature verified but issuer/audience not enforced (or loosely matched).
- Impact: tokens intended for a different service/tenant/environment can be accepted.
- Defense: pin exact
issandaudvalues per service and environment.
Vulnerable code: signature-only
// ❌ Missing iss/aud checks
const claims = jwt.verify(token, process.env.JWT_PUBLIC_KEY, { algorithms: ["RS256"] }); Defensive code: strict issuer/audience
const claims = jwt.verify(token, process.env.JWT_PUBLIC_KEY, {
algorithms: ["RS256"],
issuer: "https://auth.example",
audience: "api://my-service",
clockTolerance: 5,
}); B) exp (expiry) & nbf (not-before)
- Common bug: overly long lifetimes, ignoring time claims, or huge clock tolerance.
- Impact: stolen tokens remain useful longer; future-dated tokens may be accepted.
- Defense: short access TTL, small clock tolerance, and optional max token age.
Defensive code: enforce max age
const claims = jwt.verify(token, process.env.JWT_PUBLIC_KEY, {
algorithms: ["RS256"],
issuer: "https://auth.example",
audience: "api://my-service",
clockTolerance: 5,
maxAge: "15m", // ✅ even if exp is mis-set, cap practical lifetime
}); C) sub (subject) & authorization claims
- Common bug: treating
subor roles/scopes as permission without server-side checks. - Impact: broken access control chains if claims are too broad or enforced inconsistently.
- Defense: map
subto a real principal and enforce authorization per action/object on the server.
Vulnerable code: “role in token = access”
// ❌ Authorization from claims alone (too easy to get wrong)
if (claims.role === "admin") return doAdminThing(); Defensive code: server-side authorization
// ✅ Use claims as identity/context; enforce policy on the server
const userId = String(claims.sub || "");
const user = await db.users.findById(userId);
if (!user) return res.status(401).send("unauthorized");
// Example: object-level check (concept)
const allowed = await policy.can(user, "invoice:refund", { invoiceId: req.params.id });
return allowed ? refund() : res.status(403).send("forbidden"); 5) Key identifiers (kid) & JWKS handling (defensive view)
Many JWTs include a kid header (“key id”) that tells the verifier which key to use. This is safe only when the server controls the key set and applies strict rules.
Why kid becomes dangerous
kidis attacker-controlled input: it comes from the token header. If you use it directly in file paths, database queries, or remote fetches, it becomes an injection surface.- Common real-world mistakes: “load key from
/keys/${kid}.pem” or “SELECT key WHERE kid='${kid}'”.
A) Path traversal risk (conceptual)
If kid is concatenated into a filesystem path without strict allowlisting, an attacker may influence which file is read. That often leads to verifying with an unintended key or leaking sensitive files depending on error handling and logging.
Vulnerable code (Node.js) — file path derived from kid
import fs from "fs";
import path from "path";
// ❌ DO NOT: file path built from untrusted kid
function getKeyFromDisk(kid) {
const keyPath = path.join(process.cwd(), "keys", kid + ".pem");
return fs.readFileSync(keyPath, "utf8");
} Defensive code — allowlist map (no file paths from user input)
// ✅ Use a controlled mapping (allowlist)
const KEYRING = {
"key-2026-01": process.env.JWT_PUBLIC_KEY_2026_01,
"key-2025-10": process.env.JWT_PUBLIC_KEY_2025_10,
};
function getKeyByKid(kid) {
const k = KEYRING[kid];
if (!k) throw new Error("unknown kid");
return k;
} B) SQL injection risk (conceptual)
If kid is used to look up keys in a database and a developer builds SQL with string concatenation, kid becomes a SQL injection vector. Even if the DB is “internal,” it’s still attacker-controlled input.
Vulnerable code (Node.js) — unsafe SQL construction
// ❌ DO NOT: string concatenation with kid
const row = await db.query("SELECT public_key FROM jwk_keys WHERE kid = '" + kid + "'"); Defensive code — parameterized query + allowlist
// ✅ Parameterize AND still validate kid against known patterns/allowlist
if (!/^[a-zA-Z0-9._-]{1,64}$/.test(kid)) throw new Error("invalid kid");
const row = await db.query("SELECT public_key FROM jwk_keys WHERE kid = $1", [kid]); C) Remote JWKS fetch risk (conceptual)
- Risk pattern: letting untrusted input influence where keys are fetched from (SSRF-like behavior).
- Defense: pin issuer → JWKS URL mapping, allowlist issuers, cache keys, set strict timeouts, and never derive JWKS URL from request data.
Defensive pattern (concept)
// ✅ Concept: issuer-to-JWKS mapping is controlled by configuration, not user input.
// - Allowlist issuers
// - Cache keys (kid lookup) with sane TTL
// - Enforce algorithms + iss + aud
// - Timeouts and retries are strict kid as untrusted input and never use it directly in filesystem paths, SQL, or network fetch decisions.”Token transport & storage (where leaks happen)
- Never put tokens in URLs: URLs leak via logs, browser history, referrers, proxies, and analytics.
- Prefer Authorization header for APIs: avoids automatic cross-site sending that cookies have.
- If using cookies: use
HttpOnly,Secure,SameSite, and CSRF protections for state-changing requests. - Client storage: be cautious with browser storage; minimize token lifetime and scope; consider BFF (backend-for-frontend) patterns for web apps.
Lifecycle hardening: refresh tokens, rotation, and revocation
- Short access tokens: minutes, not days, for high-risk systems.
- Refresh rotation: each refresh use issues a new refresh token; old one becomes invalid.
- Revocation: store refresh token identifiers (or session ids) server-side so you can revoke on logout, password change, or compromise.
- Device/session management: list active sessions and allow per-device sign-out.
Confidence levels (JWT findings)
| Confidence | What you observed | What you can claim |
|---|---|---|
| Low | JWT used, but verification code path / key source / issuer rules unclear | “Potential JWT validation risks; requires code/config review of verification and claim checks.” |
| Medium | Some checks present, but gaps in algorithms/claims/lifecycle | “Likely JWT hardening needed: pin alg/keys and enforce iss/aud/exp/nbf; tighten lifetimes.” |
| High | Clear evidence of decode-without-verify, missing signature validation, or missing issuer/audience checks | “Confirmed JWT validation weakness with clear root cause and concrete remediation steps.” |
Interview-ready summaries
60-second summary
JWT security depends on verifying the signature with the correct keys and enforcing strict claims like issuer, audience, and expiry. I also pin the allowed algorithm, use short-lived access tokens with refresh rotation and revocation, and ensure tokens never leak into logs or URLs. Finally, I separate authentication from authorization: validated identity is not the same as permission.
2-minute experienced summary
I treat JWTs as untrusted input until verified. That means no decode-only logic, strict algorithm allowlists, and consistent verification middleware.
I validate claims (iss/aud/exp/nbf) and define sub semantics carefully. For key handling, I use pinned issuer-to-key mappings, safe JWKS caching, and a rotation playbook.
Operationally, access tokens are short-lived; refresh tokens rotate and are revocable so logout and compromise response are real.
I also focus on preventing leaks (no tokens in URLs/logs) and on monitoring (kid usage, failed verifications, anomaly detection).
Interview Questions & Answers (Easy → Hard)
Easy
- What is a JWT?
A: Plain: a signed token format used to carry identity/context. Deep: security depends on correct signature verification and strict claim checks. - Why is
decode()not enough?
A: Plain: it only reads data. Deep: without signature verification, claims are attacker-controlled input. - What’s the difference between access tokens and refresh tokens?
A: Plain: access is short-lived; refresh keeps sessions alive. Deep: refresh tokens must be protected, rotated, and revocable. - Which claims do you validate by default?
A: Plain: issuer, audience, and expiry. Deep: enforceiss/aud/exp/nbfwith small clock tolerance and stable semantics forsub. - JWT vs server session?
A: Plain: JWT is self-contained; sessions are server-stored. Deep: sessions simplify revocation; JWT needs short TTL + refresh rotation + revocation strategy. - What’s the most common JWT misconception?
A: Plain: “JWT equals authentication.” Deep: it’s a format; the system defines verification rules and lifecycle.
Medium
- Scenario: signature is verified but
audisn’t checked. Risk?
A: Plain: wrong token could be accepted. Deep: token confusion across services/tenants; fix by pinning exact audience. - Scenario: your verifier accepts multiple algorithms “for compatibility.” Concern?
A: Plain: larger attack surface. Deep: algorithm/key confusion becomes possible; enforce a strict allowlist (ideally one per issuer). - Scenario: long-lived access tokens (days). What changes?
A: Plain: shorten them. Deep: minutes for access + refresh rotation; add revocation and device/session management. - Scenario: tokens are stored in URLs for convenience. Why is that bad?
A: Plain: URLs leak. Deep: logs, referrers, proxies, analytics, and history can expose tokens; use headers/cookies safely. - Follow-up: How do you implement logout with JWT?
A: Plain: revoke refresh/session state. Deep: short access TTL + refresh rotation + server-side revocation makes logout real. - Follow-up: What do you log for JWT debugging without leaking secrets?
A: Plain: log metadata only. Deep: log issuer, kid, jti, and verification outcomes; never log full tokens. - Scenario: claims contain
roleand the app trusts it. What do you recommend?
A: Plain: don’t trust it as policy. Deep: treat claims as context and enforce server-side authorization per action/object. - Scenario:
kidis used to load a key from disk. What’s the risk?
A: Plain: it can point to unintended keys/files. Deep:kidis untrusted input—use allowlists, not paths derived from headers.
Hard
- Scenario: multi-tenant SaaS. How do you prevent cross-tenant token acceptance?
A: Plain: bind tokens to tenant context. Deep: strict issuer/audience per tenant or validated tenant claims + server-side tenant isolation. - Scenario: key rotation across many microservices. How do you roll it out safely?
A: Plain: overlap keys briefly. Deep: publish new key, accept old+new temporarily, monitor kid usage, then retire old with rollback plan. - Scenario: your system fetches JWKS from the network. What guardrails do you add?
A: Plain: fetch only from trusted places. Deep: pin issuer→JWKS mapping, cache, strict timeouts, no user-driven URLs, and monitor unexpected kids. - Follow-up: How do you test JWT validation without risky “payload games”?
A: Plain: automated tests. Deep: unit/integration tests for wrong issuer/audience, expired/nbf tokens, wrong algorithm, wrong key, and route coverage through shared middleware. - Scenario: ID token vs access token confusion in OIDC. How do you prevent it?
A: Plain: use each token correctly. Deep: resource servers accept access tokens with correct audience; ID tokens are for client session establishment; enforce token-type expectations. - Follow-up: What telemetry helps detect JWT issues?
A: Plain: watch failures/anomalies. Deep: verification failures, unexpected issuers/kids, refresh reuse, and unusual token usage by IP/device/time. - Scenario: signing key leak. What’s your incident response?
A: Plain: rotate and revoke. Deep: rotate signing keys, revoke refresh/session state, shorten TTL temporarily, audit usage, and harden secret storage/CI scanning. - Follow-up: When would you prefer sessions over JWT?
A: Plain: when revocation and simplicity matter. Deep: server sessions are often simpler for web apps; JWT is fine for APIs with strong lifecycle controls.