Build JWT Auth System at Production Scale


system-design jwt security architecture backend

Fair warning: this isn’t a quick scroll. If you’re a serious engineer, you’ll love it - otherwise, you might want to skip

Anyone can implement JWTs. Very few build systems that don’t fall apart because of them. This is the only blog you will ever need to understand everything about JWT


Quick navigation

  1. Why would anybody use JWT - and why is it everywhere?
  2. System overview
  3. Login flow
  4. Request verification flow
  5. Token refresh and rotation
  6. How refresh token reuse detection actually works
  7. Key management and rotation
  8. Database schema
  9. Frontend - silent refresh pattern
  10. Security hardening checklist
  11. Can you crack this interview questions

1. Why would anybody use JWT - and why is it everywhere?

The problem JWT replaces

Classic server-side sessions work like this: user logs in → server stores a session in memory or a DB → sends back a session ID cookie → every request does a lookup. Fine with one server. But once you have multiple servers behind a load balancer, Server A created your session and Server B has no idea who you are. You need sticky sessions or a shared Redis store - both add complexity and a central point of failure. JWT flips the model entirely.

What JWT does differently

Instead of the server remembering you, it hands you a signed token that proves who you are. Every request carries the identity payload with it. The server just:

  1. Receives the token
  2. Verifies the cryptographic signature
  3. Reads the claims - no DB call, no lookup

This is what stateless auth means.

Why This Matters at Scale

  • Horizontal scaling becomes trivial. Any server can verify any token independently - just needs the secret key. Spin up 50 instances and they all work immediately.
  • Microservices become cleaner. With opaque session tokens, every service has to call a central auth service on every request - that’s extra latency and a single point of failure multiplied across your architecture. With JWT, each service verifies locally in microseconds.
  • Cross-domain auth works naturally. Session cookies are tied to a domain. JWTs are just strings - they travel in an Authorization header and work across domains, in mobile apps, in CLI tools, anywhere HTTP works.

The honest tradeoffs

JWT isn’t universally better - it’s a different set of tradeoffs.

  • You can’t invalidate a JWT early. Delete a session and the user is instantly logged out. With JWT, if a token is stolen or you need to force-logout, you can’t - it’s valid until exp passes. This is why short expiry times (15 min) + refresh tokens exist.
  • The payload is encoded, not encrypted. Anyone who intercepts a JWT can read its claims - it’s just base64. Never put passwords or sensitive PII in the payload.
  • Token size adds up. A session ID is ~32 bytes. A JWT with 10 claims might be 500 bytes. At high traffic volumes, this matters.

How Big Companies Actually Solved This

They didn’t pick sessions OR JWT. They built a hybrid model that takes the best of both.

The Pattern: Opaque Token + Token Introspection Service

Browser → holds opaque session token (just a random ID, like a cookie) → sends it with every UI request

API Gateway → receives the opaque token → calls a dedicated Auth Service once → Auth Service returns a rich identity object → Gateway mints a short-lived internal JWT → passes that JWT downstream to microservices

Microservices → verify the JWT locally, no network call needed

The browser never sees a JWT. Internally, JWTs fly between services. The session store is only hit once per request at the edge, not by every downstream service.

Why This Works at Scale The Auth Service lookup happens exactly once - at the API Gateway. After that, the internal JWT carries the identity context to all 10, 50, 100 downstream services without any of them touching the session store.

1 request = 1 session store lookup (at the edge) + N JWT verifications (local, microseconds each)

Where JWT Actually Belongs

Use caseGood fit?
Service-to-service auth✅ Yes
Short-lived third-party API tokens✅ Yes
Mobile SDKs (cookies don’t work naturally)✅ Yes
User-facing login sessions in a web app⚠️ Usually overkill

The irony: JWT got popular because engineers read about how Google and Meta scaled auth for microservices - then reached for JWT for their login cookie, solving a problem they didn’t have while creating ones they didn’t expect. The tool is right. The layer matters.


Alright, that’s enough theory. I’ll assume you understand where this applies, so let’s go ahead and build it.


2. System overview

Before touching any code, let’s establish the components. A production JWT auth system has five distinct layers.

JWT auth system - component overview Five-layer architecture: client, API gateway, auth service, resource services, and data stores. Client (browser / mobile) Access token in memory · Refresh token in httpOnly cookie SPA / Native app calls /auth and /api HTTPS API gateway JWT signature verification · Rate limiting · Route dispatch JWKS cache 10 min TTL /auth/* /api/* Auth service Login · Logout · Refresh Signs tokens with RSA private key Resource services Users · Orders · Payments Trust gateway - no re-verify Postgres Users · refresh tokens token families Redis Revoked kid blocklist jti nonce store Secrets manager RSA private key AWS / Vault / HSM fetches private key on boot Public JWKS endpoint GET /.well-known/jwks.json - served by Auth service, cached by Gateway

Key architectural decisions visible here:

The API gateway is the only layer that does JWT signature verification - resource services trust the gateway and just read the decoded claims. This avoids every microservice needing to implement crypto. The auth service is the only component that ever touches the RSA private key, which lives in a secrets manager - never in an env file.


3. Login flow

The login flow is where everything starts. A successful login produces two tokens with very different lifetimes and storage locations.

JWT login flow Sequence diagram showing login request from client through gateway to auth service, credential verification, token issuance, and split storage of access and refresh tokens. Client API gateway Auth service Postgres 1 POST /auth/login { email, password } 2 forward request no JWT check on /auth/* 3 SELECT user WHERE email 4 { hash, userId, active } 5 bcrypt.compare() 6 sign RS256 JWT (15m) 7 generate refresh token 8 INSERT refresh_token { token, family, userId } 9 200 OK { accessToken } Set-Cookie: refreshToken (httpOnly) 10 accessToken → memory never localStorage

Notice step 9 - the response body carries the access token (stored in JS memory), while Set-Cookie carries the refresh token as an httpOnly cookie. JavaScript on the page never sees the refresh token value. This is the split storage pattern that defeats XSS token theft.

Auth service - login implementation

// auth/routes/login.js
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const jwt    = require('jsonwebtoken');
const fs     = require('fs');

const PRIVATE_KEY = fs.readFileSync(process.env.JWT_PRIVATE_KEY_PATH);

async function login(req, res) {
  const { email, password } = req.body;

  // 1. Find user
  const user = await db.users.findOne({ email, active: true });
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });

  // 2. Verify password - constant-time comparison via bcrypt
  const valid = await bcrypt.compare(password, user.passwordHash);
  if (!valid) return res.status(401).json({ error: 'Invalid credentials' });

  // 3. Issue access token - short lived, stateless
  const accessToken = jwt.sign(
    {
      sub:          user.id,
      role:         user.role,
      iss:          'auth.yourapp.com',
      aud:          'api.yourapp.com',
      tokenVersion: user.tokenVersion,  // for emergency invalidation
    },
    PRIVATE_KEY,
    { algorithm: 'RS256', expiresIn: '15m', keyid: process.env.JWT_KEY_ID }
  );

  // 4. Issue refresh token - long lived, opaque, stored in DB
  const refreshToken  = crypto.randomBytes(64).toString('hex');
  const familyId      = crypto.randomUUID();

  await db.refreshTokens.insert({
    token:     refreshToken,
    family:    familyId,
    userId:    user.id,
    used:      false,
    createdAt: new Date(),
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
  });

  // 5. Set refresh token as httpOnly cookie - JS blind
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure:   true,
    sameSite: 'strict',
    path:     '/auth/refresh',  // only sent to this endpoint
    maxAge:   30 * 24 * 60 * 60 * 1000,
  });

  // 6. Access token in body - UI stores in memory
  return res.json({ accessToken, expiresIn: 900 });
}

4. Request verification flow

Every API request goes through the gateway’s verification middleware before reaching a resource service. This is where the JWKS cache, kid lookup, and claim validation all happen.

JWT request verification flow Flowchart showing how an incoming API request is validated by the gateway: extract token, check kid blocklist, verify signature via JWKS, validate claims, then forward to service. Incoming API request Authorization: Bearer <token> Decode JWT header extract alg + kid (no verify yet) Check kid blocklist Redis lookup - O(1) blocked 401 key revoked clear Verify signature JWKS cache → RSA verify invalid 401 bad signature valid Validate claims exp · iss · aud · tokenVersion check jti nonce (replay guard) fails 401 claim invalid passes Forward to service inject X-User-Id, X-User-Role strip Authorization header

Gateway - verification middleware

// gateway/middleware/verifyJwt.js
const jwksClient = require('jwks-rsa');
const jwt        = require('jsonwebtoken');
const redis      = require('../lib/redis');

const jwks = jwksClient({
  jwksUri:      'https://auth.yourapp.com/.well-known/jwks.json',
  cache:        true,
  cacheMaxAge:  600_000,   // 10 min
  rateLimit:    true,
  jwksRequestsPerMinute: 10,
});

async function verifyJwt(req, res, next) {
  const authHeader = req.headers['authorization'];
  if (!authHeader?.startsWith('Bearer '))
    return res.status(401).json({ error: 'Missing token' });

  const token = authHeader.slice(7);

  // 1. Decode header without verifying - to extract kid
  let header;
  try {
    header = JSON.parse(
      Buffer.from(token.split('.')[0], 'base64url').toString()
    );
  } catch {
    return res.status(401).json({ error: 'Malformed token' });
  }

  // 2. Hard-reject explicitly blocked key IDs (compromised keys)
  const isBlocked = await redis.sIsMember('blocked_kids', header.kid);
  if (isBlocked)
    return res.status(401).json({ error: 'Key revoked - please re-authenticate' });

  // 3. Fetch public key and verify signature
  let decoded;
  try {
    decoded = await new Promise((resolve, reject) => {
      jwt.verify(
        token,
        (hdr, cb) => jwks.getSigningKey(hdr.kid, (err, key) =>
          err ? cb(err) : cb(null, key.getPublicKey())
        ),
        {
          algorithms: ['RS256'],   // never allow 'none'
          issuer:     'auth.yourapp.com',
          audience:   'api.yourapp.com',
        },
        (err, payload) => err ? reject(err) : resolve(payload)
      );
    });
  } catch (err) {
    return res.status(401).json({ error: 'Invalid token', detail: err.message });
  }

  // 4. Check token version (handles emergency invalidation)
  const user = await db.users.findById(decoded.sub);
  if (!user || decoded.tokenVersion !== user.tokenVersion)
    return res.status(401).json({ error: 'Session invalidated' });

  // 5. Replay guard - check jti nonce hasn't been seen before
  if (decoded.jti) {
    const seen = await redis.set(`jti:${decoded.jti}`, '1', {
      NX: true,   // only set if not exists
      EX: 900,    // expire with the token (15 min)
    });
    if (!seen)
      return res.status(401).json({ error: 'Token replay detected' });
  }

  // 6. Inject user context - downstream services read these headers
  req.headers['x-user-id']   = decoded.sub;
  req.headers['x-user-role']  = decoded.role;
  delete req.headers['authorization']; // don't leak downstream

  next();
}

5. Token refresh and rotation

The refresh flow is the most security-sensitive endpoint in the system. It’s where theft detection happens and where a compromised session gets terminated.

Refresh token rotation flow Decision flowchart: incoming refresh token goes through existence check, expiry check, reuse check. On reuse, entire family is revoked. On clean use, old token is marked used and a new pair is issued. POST /auth/refresh refresh token from httpOnly cookie Find token in DB SELECT * WHERE token = ? not found 401 invalid token found Check expiry expiresAt > now expired 401 token expired valid Check used flag used === true? REUSE THEFT revoke family force re-auth unused Rotate token mark old token used: true insert new token, same family Issue new token pair { accessToken } in body new refreshToken in cookie

Auth service - refresh endpoint

// auth/routes/refresh.js
const crypto = require('crypto');
const jwt    = require('jsonwebtoken');

async function refresh(req, res) {
  const oldRefreshToken = req.cookies.refreshToken;
  if (!oldRefreshToken)
    return res.status(401).json({ error: 'No refresh token' });

  // 1. Find token in DB
  const stored = await db.refreshTokens.findOne({ token: oldRefreshToken });
  if (!stored)
    return res.status(401).json({ error: 'Invalid token' });

  // 2. Check expiry
  if (stored.expiresAt < new Date())
    return res.status(401).json({ error: 'Refresh token expired' });

  // 3. THEFT DETECTION - token reuse
  if (stored.used) {
    // Attacker or real user reused a token - treat as compromise
    await db.refreshTokens.updateMany(
      { family: stored.family },
      { revoked: true, revokedReason: 'reuse_detected', revokedAt: new Date() }
    );
    // Optional: alert security team
    await alertSecurityTeam({ userId: stored.userId, event: 'token_reuse' });
    return res.status(401).json({ error: 'Token reuse detected - please log in again' });
  }

  // 4. Mark old token as used - don't delete, keep for forensics
  await db.refreshTokens.update(
    { token: oldRefreshToken },
    { used: true, usedAt: new Date() }
  );

  // 5. Get user and bump check tokenVersion
  const user = await db.users.findById(stored.userId);
  if (!user || !user.active)
    return res.status(401).json({ error: 'Account inactive' });

  // 6. Issue new access token
  const newAccessToken = jwt.sign(
    {
      sub:          user.id,
      role:         user.role,
      iss:          'auth.yourapp.com',
      aud:          'api.yourapp.com',
      tokenVersion: user.tokenVersion,
      jti:          crypto.randomUUID(),  // replay guard
    },
    PRIVATE_KEY,
    { algorithm: 'RS256', expiresIn: '15m', keyid: process.env.JWT_KEY_ID }
  );

  // 7. Issue new refresh token - same family
  const newRefreshToken = crypto.randomBytes(64).toString('hex');
  await db.refreshTokens.insert({
    token:     newRefreshToken,
    family:    stored.family,   // same family
    userId:    stored.userId,
    used:      false,
    createdAt: new Date(),
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
  });

  // 8. Set new cookie + return new access token
  res.cookie('refreshToken', newRefreshToken, {
    httpOnly: true,
    secure:   true,
    sameSite: 'strict',
    path:     '/auth/refresh',
    maxAge:   30 * 24 * 60 * 60 * 1000,
  });

  return res.json({ accessToken: newAccessToken, expiresIn: 900 });
}

6. How refresh token reuse detection actually works

This is the most misunderstood part of the system. A common question is: “if the UI calls /auth/refresh regularly, doesn’t that count as reuse?” No - and understanding why reveals exactly how theft detection works.

The UI never reuses a token

Every call to /auth/refresh returns a brand new refresh token via Set-Cookie. The browser replaces the old cookie automatically. So the next refresh call always sends the new token - the old one is already dead.

Login
└── Server issues:  token_A  (used: false)
    Browser cookie: token_A

UI calls /auth/refresh  (15 min later)
└── Sends:          token_A
    Server marks:   token_A → used: true
    Server issues:  token_B  (used: false)
    Browser cookie: token_B  ← replaces token_A automatically

UI calls /auth/refresh again
└── Sends:          token_B  ← NOT token_A, always the latest
    Server marks:   token_B → used: true
    Server issues:  token_C
    Browser cookie: token_C

The browser holds exactly one refresh token at any moment. Reuse can only come from an outside party holding a stale token.

The two theft scenarios

Refresh token reuse detection - two theft scenarios Two side-by-side timelines showing what happens when the real user refreshes first vs when the attacker refreshes first. In both cases the second party triggers the reuse alarm and the entire token family is revoked. Scenario A - user refreshes first Scenario B - attacker refreshes first Both hold token_A Attacker stole it before first refresh UI sends token_A first token_A → used:true, token_B issued UI cookie updated to token_B attacker still holds stale token_A Attacker sends token_A token_A.used === true → REUSE Entire family revoked token_B also invalidated Both parties logged out real user notices unexpected logout Both hold token_A Attacker stole it before first refresh Attacker sends token_A first token_A → used:true, token_B issued Attacker has token_B UI still holds stale token_A UI sends token_A token_A.used === true → REUSE Entire family revoked token_B also invalidated Both parties logged out attacker loses access immediately Either way - whoever goes second triggers the alarm. Both sessions terminate. The real user's unexpected logout is the signal that a theft occurred.

Why “don’t delete used tokens” is critical

This is the implementation detail that makes detection possible. If you delete the token after use, a reused token looks identical to an unknown token - you can’t tell the difference.

// ❌ Wrong - delete on use
await db.refreshTokens.delete({ token: oldToken });
// When attacker sends token_A:
//   → "not found" → generic 401
//   → no idea if it was stolen or just expired
//   → no family revocation

// ✅ Correct - mark as used, keep the row
await db.refreshTokens.update({ token: oldToken }, { used: true });
// When attacker sends token_A:
//   → found, used === true
//   → REUSE - revoke entire family
//   → both parties logged out, real user notified

The used flag is what turns a regular 401 into a security event. The row must survive after use - it’s the tripwire.

The race condition edge case

There is one edge case worth knowing: what if the UI sends two refresh requests simultaneously? (Double tab open, race condition on app boot.) Both send token_A - the first succeeds, the second hits used: true and triggers a false alarm.

The fix is an atomic DB operation using SELECT FOR UPDATE or an optimistic lock:

// Atomic check-and-mark using a single UPDATE
// Only succeeds if used is still false
const result = await db.refreshTokens.updateOne(
  { token: oldToken, used: false },  // condition
  { $set: { used: true, usedAt: new Date() } },
  { returnDocument: 'before' }       // return pre-update state
);

if (!result) {
  // Either token doesn't exist OR was already used
  // Check which - if it existed and was used, it's reuse
  const existing = await db.refreshTokens.findOne({ token: oldToken });
  if (existing?.used) {
    await revokeFamily(existing.family);
    return res.status(401).json({ error: 'Token reuse detected' });
  }
  return res.status(401).json({ error: 'Invalid token' });
}

// result is the pre-update doc - proceed with rotation

This makes the check-and-mark atomic so two simultaneous legitimate requests can’t both succeed and accidentally trigger a false family revocation.



7. Key management and rotation

Key management is what separates a production auth system from a demo. The private key is the single point of trust for your entire authentication layer - if it leaks, every token ever issued by it is compromised.


Where the key lives

Never in an env file. Never on disk. Never in your repo. The private key must live in a dedicated secrets store and be fetched at runtime.

❌  JWT_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----...   (env file)
❌  /app/keys/private.pem                                 (disk)
❌  git commit -m "add key"                               (repo)

✅  AWS Secrets Manager
✅  HashiCorp Vault
✅  GCP Secret Manager
✅  Hardware Security Module (HSM) - strongest option

The auth service fetches the key once at startup and holds it in memory. Nothing else in the system ever sees it.

// auth/lib/keys.js - fetch on boot, never log or expose
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');

let _privateKey = null;
let _currentKid = null;

async function loadSigningKey() {
  const client = new SecretsManagerClient({ region: 'ap-south-1' });

  const result = await client.send(
    new GetSecretValueCommand({ SecretId: 'jwt/private-key' })
  );

  const secret = JSON.parse(result.SecretString);
  _privateKey  = secret.privateKey; // PEM string
  _currentKid  = secret.kid;        // e.g. "key-2026-04"

  console.log(`Signing key loaded: ${_currentKid}`);
  // NEVER log the key itself - only the kid
}

module.exports = {
  loadSigningKey,
  getPrivateKey: () => _privateKey,
  getCurrentKid: () => _currentKid,
};

What kid is and why it matters

Every JWT has a kid (key ID) stamped in its header when signed:

{
  "alg": "RS256",
  "kid": "key-2026-04",
  "typ": "JWT"
}

When a service verifies the token, it reads the kid, fetches the matching public key from the JWKS endpoint, and verifies the signature with that specific key. This is what makes rotation seamless - multiple keys can be active simultaneously and each token knows exactly which one signed it.


The JWKS endpoint

The auth service publishes all currently active public keys at a standard URL. Gateways and services fetch and cache from here - they never need the private key.

// auth/routes/jwks.js
const publicKeys = new Map([
  // kid → PEM public key
  // Add new entries here when rotating, remove old ones after expiry window
  ['key-2026-04', process.env.PUBLIC_KEY_2026_04],
  ['key-2026-05', process.env.PUBLIC_KEY_2026_05], // new key during rotation
]);

app.get('/.well-known/jwks.json', (req, res) => {
  res.set('Cache-Control', 'public, max-age=600'); // gateways cache 10 min

  const keys = Array.from(publicKeys.entries()).map(([kid, pem]) => {
    const pubComponents = extractRsaComponents(pem); // n and e
    return {
      kid,
      kty: 'RSA',
      use: 'sig',
      alg: 'RS256',
      n:   pubComponents.n,
      e:   pubComponents.e,
    };
  });

  res.json({ keys });
});

Planned rotation - zero downtime

Routine key rotation should happen every 90 days. The overlap window is what makes it zero downtime - old tokens keep working until they expire naturally.

Day 0  (today)
  └── Active key: key-2026-04
      JWKS: [ key-2026-04 ]
      All tokens signed with key-2026-04

Day 90 (rotation day)
  Step 1: Generate new key pair → kid: key-2026-07
  Step 2: Upload new private key to secrets manager
  Step 3: Add key-2026-07 public key to JWKS endpoint
          JWKS: [ key-2026-04, key-2026-07 ]  ← both active
  Step 4: Rolling restart auth service → now signs with key-2026-07
          New tokens → key-2026-07
          Old tokens → key-2026-04  (still verified, still in JWKS)

Day 90 + 15min (access tokens expired)
  Step 5: Remove key-2026-04 from JWKS
          JWKS: [ key-2026-07 ]
          Old key-2026-04 tokens have all expired naturally ✓
# Step 1 - Generate new key pair
openssl genrsa -out new-private.pem 4096
openssl rsa -in new-private.pem -pubout -out new-public.pem

# Step 2 - Upload to secrets manager
aws secretsmanager put-secret-value \
  --secret-id jwt/private-key \
  --secret-string "{\"privateKey\":\"$(cat new-private.pem)\",\"kid\":\"key-2026-07\"}"

# Step 3 - Rolling restart (Kubernetes)
kubectl rollout restart deployment/auth-service

Emergency rotation - key compromised

This is the nuclear scenario. You don’t have time for a graceful overlap window - every token signed with the compromised key must be treated as untrusted immediately.

Minute 0:  Compromise detected
Minute 1:  Generate new key pair
Minute 2:  Upload new private key to secrets manager
Minute 3:  Rolling restart auth service (signs with new key)
Minute 4:  Blocklist old kid in Redis
Minute 5:  Force re-auth for all users
Minute 6:  Remove old key from JWKS
// Step 4 - Blocklist the compromised kid
// Gateway checks this on every request before signature verification
await redis.sAdd('blocked_kids', 'key-2026-04');

// Any token with kid=key-2026-04 is now rejected at the gateway
// even if the signature is cryptographically valid
// Step 5 - Force re-auth for all users
// Two-pronged: revoke refresh tokens AND bump tokenVersion

async function emergencyInvalidateAll() {
  // Revoke all refresh tokens - no one can silently restore a session
  await db.refreshTokens.updateMany(
    { revoked: false },
    {
      revoked:       true,
      revokedReason: 'key_compromise',
      revokedAt:     new Date(),
    }
  );

  // Bump tokenVersion on every user
  // Any in-flight access token with an older version fails claim check
  await db.users.updateMany(
    {},
    { $inc: { tokenVersion: 1 } }
  );
}

The blocklist handles tokens already in flight. The tokenVersion bump handles any tokens the blocklist misses - for example, tokens signed with the new key but issued before you detected the breach.


The overlap window visualised

Timeline →

key-2026-04  ████████████████████░░░░  (signs until restart, verified until removed)
key-2026-07              ░░░░████████████████████  (signs after restart)
                         ↑              ↑
                    rotation day    JWKS cleanup
                    both keys in    (15 min later
                    JWKS here       for access tokens,
                                    or never for
                                    emergency rotation)

During the overlap window, the JWKS endpoint serves both public keys. Tokens signed with either key verify successfully - the kid tells the verifier which one to use. Once the overlap window closes and the old key is removed from JWKS, any remaining old-kid tokens fail verification, but by then they’ve all expired naturally.


Key rotation checklist

StepPlannedEmergency
Generate new key pair
Upload to secrets manager
Add new public key to JWKS
Restart auth serviceRolling (no downtime)Immediate
Blocklist old kid in RedisNot needed
Revoke all refresh tokensNot needed
Bump tokenVersion on usersNot needed
Remove old key from JWKSAfter token expiry (15 min)Immediately

The key difference: planned rotation relies on natural token expiry to drain old tokens. Emergency rotation can’t wait - it blocklists the old kid and forces everyone to re-authenticate immediately.


8. Database schema

The refresh token table is the stateful backbone of the system. Every security decision - rotation, theft detection, family revocation - depends on querying it efficiently.

-- Users table
CREATE TABLE users (
  id             UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email          TEXT UNIQUE NOT NULL,
  password_hash  TEXT NOT NULL,
  role           TEXT NOT NULL DEFAULT 'user',
  token_version  INT  NOT NULL DEFAULT 0,   -- bump to invalidate all JWTs
  active         BOOL NOT NULL DEFAULT true,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Refresh tokens table
CREATE TABLE refresh_tokens (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  token        TEXT UNIQUE NOT NULL,          -- opaque random hex
  family       UUID NOT NULL,                 -- groups tokens from same login
  user_id      UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  used         BOOL NOT NULL DEFAULT false,   -- tripwire for theft detection
  revoked      BOOL NOT NULL DEFAULT false,
  revoked_reason TEXT,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  used_at      TIMESTAMPTZ,
  expires_at   TIMESTAMPTZ NOT NULL
);

-- Indexes - every query path needs one
CREATE INDEX idx_rt_token    ON refresh_tokens(token);
CREATE INDEX idx_rt_family   ON refresh_tokens(family);
CREATE INDEX idx_rt_user     ON refresh_tokens(user_id);
CREATE INDEX idx_rt_expires  ON refresh_tokens(expires_at);

-- Cleanup job - run nightly
DELETE FROM refresh_tokens
WHERE expires_at < now() - INTERVAL '7 days'
  AND used = true;

9. Frontend - silent refresh pattern

The UI never stores tokens in localStorage. The access token lives in a JS module variable and is silently refreshed before it expires.

// auth/client.js
let _accessToken = null;
let _refreshTimer = null;

export async function login(email, password) {
  const res = await fetch('/auth/login', {
    method:      'POST',
    credentials: 'include',  // sends/receives cookies
    headers:     { 'Content-Type': 'application/json' },
    body:        JSON.stringify({ email, password }),
  });

  if (!res.ok) throw new Error('Login failed');

  const { accessToken, expiresIn } = await res.json();
  _accessToken = accessToken;
  scheduleRefresh(expiresIn);
  return accessToken;
}

export async function refreshTokens() {
  const res = await fetch('/auth/refresh', {
    method:      'POST',
    credentials: 'include',  // httpOnly cookie sent automatically
  });

  if (!res.ok) {
    _accessToken = null;
    window.location.href = '/login';
    return;
  }

  const { accessToken, expiresIn } = await res.json();
  _accessToken = accessToken;
  scheduleRefresh(expiresIn);
}

function scheduleRefresh(expiresInSeconds) {
  clearTimeout(_refreshTimer);
  // Refresh 60 seconds before expiry
  _refreshTimer = setTimeout(refreshTokens, (expiresInSeconds - 60) * 1000);
}

export async function apiFetch(url, options = {}) {
  if (!_accessToken) await refreshTokens(); // try to restore session

  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      'Authorization': `Bearer ${_accessToken}`,
    },
  });
}

export async function logout() {
  clearTimeout(_refreshTimer);
  _accessToken = null;
  await fetch('/auth/logout', { method: 'POST', credentials: 'include' });
}

What happens on tab close

Tab closes

  • JS memory wiped → access token gone
  • httpOnly cookie → still alive (it’s a persistent cookie)

User reopens tab

  • accessToken = null → can’t make API calls
  • refreshToken cookie → still there, browser sends it automatically

So on reopen, accessToken is null but the cookie survives - just silently call /auth/refresh on page load before the user sees anything.

Silent restore on page load

// App entry point - React example
import { initAuth } from './auth/client';

async function bootstrap() {
  const restored = await initAuth();

  if (restored) {
    renderApp();       // user sees their dashboard
  } else {
    renderLoginPage(); // cookie expired or never existed
  }
}

bootstrap();

The user experience is seamless - they close the tab, come back the next day, the app calls /auth/refresh silently on boot, gets a new access token from the cookie, and lands straight on their dashboard. They never see a login page unless the refresh token itself has expired (30 days).


10. Security hardening checklist

ControlImplementationWhy
RS256 onlyalgorithms: ['RS256'] in verifyBlocks alg:none attack
httpOnly cookieres.cookie(..., { httpOnly: true })Defeats XSS token theft
sameSite: stricton refresh token cookieBlocks CSRF on refresh endpoint
Short access token TTLexpiresIn: '15m'Limits stolen token window
Refresh token rotationmark used + issue newDetects stolen refresh tokens
Family revocationrevoke all on reuseTerminates both parties on theft
kid blocklistRedis set, checked pre-verifyEmergency key compromise response
tokenVersion claimincremented on force-logoutInvalidates all issued JWTs instantly
jti nonceUUID per token, checked in RedisPrevents token replay attacks
Private key in HSMAWS Secrets Manager / VaultKey exfiltration resistance
JWKS cache10 min TTL at gatewayPerformance + resilience
path scopingcookie path: ‘/auth/refresh’Cookie only sent to one endpoint

The single most important decision in this entire design is where you store the refresh token. httpOnly cookie with sameSite:strict is not optional - it is the foundation that makes every other control meaningful. Without it, XSS bypasses the entire token rotation system.

11. Can you crack this interview questions