X Xerobit

JWT Best Practices — Secure JSON Web Token Implementation

JWT implementation mistakes lead to authentication bypasses and security vulnerabilities. Here's how to sign, validate, and store JWTs correctly — covering algorithm confusion,...

Mian Ali Khalid · · 6 min read
Use the tool
JWT Decoder
Decode and inspect JSON Web Tokens. Local-only — tokens never leave your browser.
Open JWT Decoder →

JWTs are powerful but easy to implement insecurely. Common mistakes include using weak algorithms, skipping expiration, storing tokens in localStorage, and not validating claims properly. Here’s how to implement JWTs correctly.

Use the JWT Decoder to decode and inspect JWT tokens.

JWT structure

A JWT is three base64url-encoded parts separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNzA5NTUxMjM0fQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
└──────────────────────────────────────────────┘ └────────────────────────────────────────────────┘ └──────────────────────────────────────────────┘
                   header                                          payload                                         signature

Header (decoded):

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

Payload (decoded):

{
  "sub": "1234567890",
  "name": "Alice",
  "iat": 1709551234,
  "exp": 1709554834
}

The signature verifies the header + payload haven’t been tampered with.

Algorithm selection

Use RS256 or ES256, not HS256 for APIs

HS256 — symmetric (HMAC):
- Same secret for signing AND verifying
- If your API server is compromised, attacker can create valid JWTs
- Fine for internal single-service auth

RS256 — asymmetric (RSA):
- Private key signs, public key verifies
- Resource servers only need public key
- Compromise of resource server doesn't enable token forgery

For APIs consumed by third parties or microservices:

// Generate RS256 key pair:
const { privateKey, publicKey } = await crypto.subtle.generateKey(
  { name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
  true,
  ['sign', 'verify']
);

// Node.js (jose library):
import { generateKeyPair, SignJWT, jwtVerify } from 'jose';

const { privateKey, publicKey } = await generateKeyPair('RS256');

// Sign with private key:
const token = await new SignJWT({ sub: 'user-123' })
  .setProtectedHeader({ alg: 'RS256' })
  .setIssuedAt()
  .setExpirationTime('1h')
  .sign(privateKey);

// Verify with public key:
const { payload } = await jwtVerify(token, publicKey);

The “alg: none” attack

Never accept "alg": "none" — it means the token has no signature and anyone can forge it:

// WRONG: vulnerable to alg:none attack
const payload = jwt.verify(token, secret);

// CORRECT: explicitly specify allowed algorithms
const payload = jwt.verify(token, secret, { algorithms: ['HS256'] });
// or:
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });

The HS256/RS256 confusion attack

If your code accepts both HS256 and RS256, an attacker can sign a JWT with HS256 using your PUBLIC key (which is public information):

// WRONG: accepts any algorithm
jwt.verify(token, publicKey);

// CORRECT: whitelist algorithms explicitly
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

Required claims

Every JWT should have:

const token = await new SignJWT({
  sub: userId,       // Who the token is about
  iss: 'myapp.com',  // Who issued the token
  aud: 'myapp.com',  // Intended audience
})
  .setProtectedHeader({ alg: 'RS256' })
  .setIssuedAt()          // iat: issued at
  .setExpirationTime('1h') // exp: expires in 1 hour
  .setJti(randomUUID())    // jti: unique token ID (for revocation)
  .sign(privateKey);

// Verification with claim validation:
const { payload } = await jwtVerify(token, publicKey, {
  issuer: 'myapp.com',
  audience: 'myapp.com',
});

Expiration strategy

Access tokens: Short-lived (15 minutes to 1 hour). Limits damage from stolen tokens.

Refresh tokens: Longer-lived (days to weeks). Stored securely. Used to get new access tokens.

// Issue both tokens at login:
async function issueTokens(userId) {
  const accessToken = await createJWT({ sub: userId, type: 'access' }, '15m');
  const refreshToken = await createJWT({ sub: userId, type: 'refresh', jti: uuid() }, '7d');
  
  // Store refresh token JTI in database for revocation:
  await db.refreshTokens.create({ userId, jti: parseJWT(refreshToken).jti });
  
  return { accessToken, refreshToken };
}

// Refresh flow:
async function refreshAccessToken(refreshToken) {
  const { payload } = await jwtVerify(refreshToken, publicKey);
  
  // Check token is not revoked:
  const stored = await db.refreshTokens.findOne({ jti: payload.jti });
  if (!stored || stored.revoked) throw new Error('Token revoked');
  
  return createJWT({ sub: payload.sub, type: 'access' }, '15m');
}

Token storage

HttpOnly cookies (recommended for web):

// Set cookie:
res.cookie('accessToken', token, {
  httpOnly: true,   // JS can't access it (prevents XSS theft)
  secure: true,     // HTTPS only
  sameSite: 'lax',  // CSRF protection
  maxAge: 15 * 60 * 1000,  // 15 minutes
});

// Read cookie (automatic via fetch with credentials):
fetch('/api/data', { credentials: 'include' });

localStorage (avoid for sensitive tokens):

  • Accessible to JavaScript — XSS can steal tokens
  • No CSRF risk (no automatic inclusion)
  • Fine for non-sensitive data, but not auth tokens

In-memory (for SPAs):

  • Only exists while page is open
  • Can’t survive page refresh without secure refresh mechanism
  • Not accessible from XSS in other browser tabs

Token revocation

JWTs are stateless — once issued, they’re valid until expiration. To revoke them:

Option 1: Short expiration (simplest) Keep access tokens short-lived (15 minutes). Stolen token is only valid for 15 minutes.

Option 2: JTI blocklist (more complex) Store revoked JTI values in Redis. Check on every request:

async function verifyToken(token) {
  const { payload } = await jwtVerify(token, publicKey);
  
  // Check if JTI is revoked:
  const isRevoked = await redis.exists(`revoked_jti:${payload.jti}`);
  if (isRevoked) throw new Error('Token revoked');
  
  return payload;
}

async function revokeToken(jti, expiresAt) {
  // Store in Redis until natural expiration:
  await redis.setex(`revoked_jti:${jti}`, Math.floor(expiresAt - Date.now()/1000));
}

Option 3: Version counter Store a token version per user. Increment to invalidate all tokens:

// In JWT payload:
{ sub: userId, version: 3 }

// Verification:
const user = await db.users.findById(payload.sub);
if (user.tokenVersion !== payload.version) {
  throw new Error('Token invalidated');
}

Related posts

Related tool

JWT Decoder

Decode and inspect JSON Web Tokens. Local-only — tokens never leave your browser.

Written by Mian Ali Khalid. Part of the Dev Productivity pillar.