🛡️ Application Security CheatSheet

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.

Key idea: JWT is not “magic login.” Security depends on verification (signature + strict claim checks) and lifecycle (short expiry, rotation, revocation).
Interview-safe framing: “JWT is a token format. Whether it’s secure depends on how we validate it, what we put inside it, and how we manage its keys and expiration.”

Mental model

  1. Header: says which algorithm is used (e.g., HS256, RS256) and may include a key identifier (kid).
  2. Payload (claims): identity and authorization context (e.g., sub, roles/scopes, iss, aud, exp).
  3. Signature: cryptographic proof the token was issued by a trusted issuer and not modified.
  4. Access token: short-lived token to call APIs.
  5. Refresh token: longer-lived credential to obtain new access tokens (must be strongly protected; ideally stored server-side or bound to device).
experienced rule: APIs should verify tokens on every request, and authorization decisions should be based on server-side policy + validated claims.

JWT verification rules (non-negotiables)

Pattern behind many JWT incidents: “We verified something… but not the right thing” (wrong key, wrong algorithm, missing claim checks, or trusting decoded content).

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" });
  }
});
Operational note: Keep access tokens short-lived. Use refresh token rotation + revocation for long sessions.

Safe validation (defensive verification)

Do not validate JWT security by “trying payloads.” Validate by reviewing verification code paths, configuration, key handling, and lifecycle behavior.

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.

experienced mindset: “JWT security = (verification correctness) + (claim correctness) + (operational lifecycle).”

1) Mis-validation patterns (high risk)

A) Decoding without verification (decode() / parsing only)

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)

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

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*)

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,
  });
}
Interview phrasing: “We pin algorithm and key source server-side; we never let the token header choose verification behavior.”

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.

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
  });
}
If you can, move to asymmetric signing so verifiers don’t need shared secrets.

4) Claim misconfiguration (where “verified” tokens still fail security)

A) iss (issuer) & aud (audience)

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)

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

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");
experienced guidance: “Verify crypto + verify claims + enforce authorization separately.”

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

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)

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
Interview phrasing: “We treat kid as untrusted input and never use it directly in filesystem paths, SQL, or network fetch decisions.”

Token transport & storage (where leaks happen)

A perfectly verified JWT is still dangerous if it leaks. Lifecycle and storage are part of security.

Lifecycle hardening: refresh tokens, rotation, and revocation

Interview line: “Logout should invalidate refresh/session state; access tokens expire quickly, so a stolen access token has limited blast radius.”

Confidence levels (JWT findings)

ConfidenceWhat you observedWhat 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)

How to answer JWT questions: Start with “verify signature + pin algorithm + validate claims + lifecycle (short TTL, refresh rotation, revocation)”. Avoid payload talk; focus on root causes, guardrails, and tests.

Easy

  1. 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.
  2. Why is decode() not enough?
    A: Plain: it only reads data. Deep: without signature verification, claims are attacker-controlled input.
  3. 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.
  4. Which claims do you validate by default?
    A: Plain: issuer, audience, and expiry. Deep: enforce iss/aud/exp/nbf with small clock tolerance and stable semantics for sub.
  5. 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.
  6. 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

  1. Scenario: signature is verified but aud isn’t checked. Risk?
    A: Plain: wrong token could be accepted. Deep: token confusion across services/tenants; fix by pinning exact audience.
  2. 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).
  3. Scenario: long-lived access tokens (days). What changes?
    A: Plain: shorten them. Deep: minutes for access + refresh rotation; add revocation and device/session management.
  4. 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.
  5. 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.
  6. 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.
  7. Scenario: claims contain role and 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.
  8. Scenario: kid is used to load a key from disk. What’s the risk?
    A: Plain: it can point to unintended keys/files. Deep: kid is untrusted input—use allowlists, not paths derived from headers.

Hard

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.