# JWT Authentication: The Parts Nobody Explains Clearly
By Govind Bajaj · 2024

Tags: jwt, authentication, security, nodejs, web-development
I have seen 47 JWT tutorials. 46 of them show how to sign a token. One explains what happens when it leaks. Here's the full picture, not just the happy path.
I have read forty-seven JWT tutorials over the years.

Forty-six of them showed how to sign a token with jsonwebtoken and verify it on the server. One explained what actually happens when a token leaks, how to revoke compromised tokens, and why refresh token rotation matters.

Here is the full picture — the parts that production systems need, not just the happy path.

## How JWT Actually Works

A JSON Web Token has three parts separated by dots: header.payload.signature.

The header contains the algorithm and token type. The payload contains claims like user ID, role, and expiry. The signature is a hash of header plus payload plus your secret key.

When you verify a token, you recalculate the signature using your secret. If it matches, the token is authentic. If the payload was tampered with, the signatures will not match.

This is not encryption. The payload is base64-encoded, not encrypted. Anyone can decode it. The signature only proves it has not been tampered with.

## The Signing Code Everyone Shows

```ts
import jwt from 'jsonwebtoken'

// Sign — shown in every tutorial
const token = jwt.sign(
  { userId: user._id, role: user.role },
  process.env.JWT_SECRET!,
  { expiresIn: '7d' }
)

// Verify — also shown everywhere
const decoded = jwt.verify(token, process.env.JWT_SECRET!)
```

This works for demos. It fails for production.

## Problem 1: You Cannot Revoke Tokens

Once issued, a JWT is valid until it expires. If a user logs out, changes their password, or gets their account compromised, the token is still valid. There is no server-side invalidation mechanism built in.

Solutions ranked from best to worst:

1. Short expiry plus refresh tokens. Access tokens expire in 15 minutes. Refresh tokens are stored server-side and can be revoked.
2. Token blacklist in Redis. Check every incoming token against a revocation list. Adds latency but works.
3. Just accept the risk for low-sensitivity applications. Not recommended for e-commerce or financial data.

## Problem 2: Secret Key Management

A single JWT secret means all tokens are compromised if the secret leaks. Rotation requires invalidating every active token.

Better approach: use asymmetric keys (RS256). Sign tokens with a private key. Verify with a public key. The public key can be distributed to edge servers without exposing the private key.

```ts
// Generate key pair (one-time setup)
import { generateKeyPairSync } from 'crypto'

const { privateKey, publicKey } = generateKeyPairSync('rsa', {
  modulusLength: 2048,
})

// Sign with private key
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256' })

// Verify with public key (can be distributed)
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] })
```

## Problem 3: Storing Tokens on the Client

localStorage is vulnerable to XSS attacks. Cookies with httpOnly are vulnerable to CSRF attacks unless you use SameSite and CSRF tokens.

The safest pattern for 2025:

```ts
// Set refresh token as httpOnly cookie (sent automatically, JS cannot read)
res.cookie('refreshToken', refreshToken, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'strict',
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
})

// Return access token in response body (short-lived, 15 minutes)
res.json({ accessToken })
```

The access token is stored in memory (not localStorage, not cookies). The refresh token is an httpOnly cookie. On page reload, a silent refresh endpoint exchanges the cookie for a new access token.

## Problem 4: Clock Skew and Expiry

JWT exp (expiry) is checked against the server clock. If your server clock is 5 minutes slow, tokens appear valid 5 minutes longer than they should. If it is fast, tokens expire early and users get mysterious logout issues.

Use NTP on all servers. Add a leeway option during verification:

```ts
jwt.verify(token, secret, { clockTolerance: 60 }) // 60 second tolerance
```

## The Complete Production Pattern

```ts
// tokens.ts
import jwt from 'jsonwebtoken'

const ACCESS_EXPIRY = '15m'
const REFRESH_EXPIRY = '7d'

export function generateAccessToken(user: User) {
  return jwt.sign(
    { userId: user._id, role: user.role, type: 'access' },
    process.env.JWT_SECRET!,
    { expiresIn: ACCESS_EXPIRY }
  )
}

export function generateRefreshToken(user: User) {
  return jwt.sign(
    { userId: user._id, type: 'refresh', jti: crypto.randomUUID() },
    process.env.JWT_REFRESH_SECRET!,
    { expiresIn: REFRESH_EXPIRY }
  )
}

// Store refresh token hash in DB for revocation support
export async function storeRefreshToken(userId: string, token: string) {
  const hash = await bcrypt.hash(token, 10)
  await RefreshToken.create({
    userId,
    hash,
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
  })
}
```

## Takeaways

- JWTs cannot be revoked — use short expiry (15 minutes) with refresh tokens for production
- Use RS256 (asymmetric) instead of HS256 (symmetric) — the private key stays on one server
- Never store tokens in localStorage — use httpOnly cookies for refresh tokens, memory for access tokens
- NTP sync all servers to prevent clock skew issues
- Store refresh token hashes server-side so you can revoke them
- The payload is not encrypted — do not put sensitive data in JWT claims
- Token type claims (access vs refresh) prevent cross-token abuse