How to Implement JWT Authentication Properly — Access Tokens, Refresh Tokens, and Common Mistakes

By banditz

Friday, January 9, 2026 • 7 min read

Diagram showing JWT token flow between client and server

Every JWT tutorial on the internet follows the same pattern. User sends credentials. Server signs a JWT. Client stores it in localStorage. Client sends it in the Authorization header. Tutorial ends.

That implementation has at least three serious problems.

First, localStorage is accessible to any JavaScript on the page. One XSS vulnerability — one unsanitized input, one compromised third-party script — and the attacker reads and exfiltrates the token.

Second, no refresh mechanism. The token either expires quickly (user logs in constantly) or slowly (stolen token has a long window).

Third, no revocation. If the user changes their password or you detect suspicious activity, you can’t invalidate the token. It’s valid until it expires.

The Access / Refresh Token Pattern

OAuth2 standardized this approach with two tokens:

Access Token — Short-lived (15 minutes). Sent with every API request. If stolen, 15-minute window.

Refresh Token — Long-lived (7-30 days). Stored securely. Only sent to a refresh endpoint. Used to get new access tokens.

The flow:

  1. User logs in with credentials
  2. Server validates, generates access token (15 min) and refresh token (7 days)
  3. Client stores both securely
  4. Client sends access token with API requests
  5. When access token expires, client sends refresh token to refresh endpoint
  6. Server validates refresh token, issues new access token (and rotates refresh token)
  7. When refresh token expires, user must log in again

Step 1: Token Claims

{

  "iss": "api.yourdomain.com",

  "sub": "user_12345",

  "aud": "yourdomain.com",

  "exp": 1712345678,

  "iat": 1712344778,

  "jti": "unique-id-abc123",

  "role": "admin"

}
  • iss — who created the token
  • sub — user ID
  • aud — intended audience
  • exp — expiration (Unix timestamp)
  • iat — issued at
  • jti — unique token ID (needed for revocation)

Never put sensitive data in the payload. JWTs are base64 encoded, not encrypted. Anyone can decode the payload. No passwords, no SSNs, no credit card numbers.

Keep it minimal. User ID and role. That’s it. Look up everything else server-side.

Step 2: Signing Algorithm

Use asymmetric signing for production.

RS256 (RSA + SHA-256) or ES256 (ECDSA + SHA-256). The auth server holds the private key and signs tokens. API servers have only the public key and can verify tokens but cannot create them.

This matters in a microservices architecture. If you use HS256 (symmetric), every service that verifies tokens has the shared secret. Compromise one service, compromise the signing key, and the attacker can mint arbitrary tokens.

With RS256/ES256, compromising an API server doesn’t help — the attacker can verify tokens but can’t sign new ones. Only the auth server can do that.

Generate an RS256 key pair:

openssl genrsa -out private.pem 2048

openssl rsa -in private.pem -pubout -out public.pem

The auth server uses private.pem to sign. API servers use public.pem to verify.

Step 3: Secure Token Storage

Where NOT to store tokens:

  • localStorage — accessible to any JavaScript. XSS = game over.
  • sessionStorage — same problem, just doesn’t persist across tabs.
  • Regular cookies without httpOnly — JavaScript can still read them.
  • URL parameters — visible in server logs, browser history, referrer headers.

Where to store them:

For web apps: httpOnly cookies with Secure and SameSite attributes.

res.cookie('access_token', token, {

    httpOnly: true,

    secure: true,

    sameSite: 'strict',

    maxAge: 900000   // 15 minutes

});

httpOnly means JavaScript cannot access the cookie. XSS can’t steal it. Secure means HTTPS only. SameSite: strict prevents the cookie from being sent in cross-origin requests, mitigating CSRF.

The browser sends the cookie automatically with every request to your domain. Your API reads it from the cookie header instead of the Authorization header. No JavaScript involved in token handling at all.

For mobile apps: Use platform secure storage — iOS Keychain, Android Keystore. These are hardware-backed encrypted storage that the OS protects.

For SPAs calling APIs on different domains: This is the hard case. SameSite: strict doesn’t work cross-origin. You need SameSite: none; Secure with proper CORS configuration. Or use the Backend For Frontend (BFF) pattern where your SPA talks to its own backend, and that backend handles tokens and proxies API calls.

Step 4: The Refresh Flow

When the access token expires, the client receives a 401 from the API. The client then sends the refresh token to a dedicated endpoint:

POST /auth/refresh

Cookie: refresh_token=eyJ...

The server:

  1. Validates the refresh token (signature, expiration, not revoked)
  2. Issues a new access token
  3. Rotates the refresh token — issues a new refresh token and invalidates the old one
  4. Returns both to the client

Refresh token rotation is critical for detecting theft. Here’s why:

If an attacker steals the refresh token and uses it before the legitimate user, the attacker gets a new token pair and the old refresh token is invalidated. When the legitimate user tries to refresh with the old token, it fails — and you know the refresh token was compromised. At this point, invalidate all tokens for that user and force re-authentication.

Store refresh tokens in a database (not just in-memory). Each refresh token maps to a user ID and a token family. When a refresh is requested:

  1. Look up the token in the database
  2. If it’s valid: issue new pair, mark old token as used, store new token
  3. If it’s already been used: someone is replaying a stolen token. Invalidate the entire family. Force the user to log in again.

Step 5: Token Revocation

JWTs are stateless — you can’t invalidate them server-side. The whole point is that the server doesn’t store session state. But sometimes you need to revoke a token immediately: logout, password change, compromised account.

Option 1: Token blocklist.

Store revoked token JTIs (unique IDs) in Redis with a TTL matching the token’s remaining lifetime. On every API request, check the blocklist:

const isRevoked = await redis.get(`blocklist:${tokenJti}`);

if (isRevoked) return res.status(401).json({ error: 'Token revoked' });

This trades some statefulness for the ability to revoke. The blocklist is small (only active tokens that have been explicitly revoked) and the TTL ensures automatic cleanup.

Option 2: Short access tokens + refresh token revocation.

Keep access tokens at 5 minutes. For logout, only revoke the refresh token (delete it from the database). The access token expires naturally within 5 minutes. This is simpler but has a 5-minute window where the old access token still works.

Option 3: Token versioning.

Store a tokenVersion counter on the user record. Include it in the JWT payload. On every request, compare the token’s version to the database. If the user’s version has been incremented (due to password change, forced logout, etc.), reject the token. This requires a database lookup per request, which somewhat defeats the stateless advantage — but it’s a pragmatic compromise.

Common Mistakes

Mistake 1: Storing the secret in code. Use environment variables or a secrets manager. Never commit signing keys to version control.

Mistake 2: Using HS256 in a microservice architecture. Every service needs the shared secret. One compromised service = all services compromised. Use RS256/ES256.

Mistake 3: Not validating aud and iss claims. A token signed by your auth server but intended for a different service should be rejected. Always validate audience and issuer.

Mistake 4: Putting too much in the payload. Every byte is sent with every request. JWTs over 1KB are a sign you’re using them wrong.

Mistake 5: Never rotating refresh tokens. If a refresh token is valid for 30 days and gets stolen on day 1, the attacker has 29 days of access. Rotation limits this to a single use.

Mistake 6: No refresh token at all. A long-lived access token is the worst of both worlds — can’t be revoked and gives a long attack window. Use the two-token pattern.

The two-token pattern with httpOnly cookies, asymmetric signing, and refresh token rotation is the production standard. It’s not the simplest implementation, but it’s the one that doesn’t get you into the security news.


If you found this guide helpful, check out our other resources:

  • (More articles coming soon in Backend Engineering)

Step-by-Step Guide

1

Understand access and refresh token pattern

Access token is short-lived at 15 minutes sent with every request. Refresh token is long-lived at 7 days used only to get new access tokens. Separation limits damage from stolen tokens. A stolen access token expires in 15 minutes.

2

Generate tokens with proper claims and signing

Use RS256 or ES256 asymmetric signing. Include iss sub aud exp iat and jti claims. Never put passwords or sensitive data in the payload. Keep payload minimal with user ID and roles.

3

Store tokens securely

Never use localStorage because XSS can steal tokens. Use httpOnly secure SameSite cookies for web apps. Use platform secure storage for mobile. Browser sends cookies automatically and JavaScript cannot access them.

4

Implement refresh flow

When access token expires send refresh token to a dedicated endpoint. Server validates it and issues new access token. Rotate refresh tokens on each use. If old refresh token is reused after rotation invalidate all tokens for that user as theft is detected.

5

Handle revocation

Maintain a token blocklist in Redis with JTI until expiration. For password changes invalidate all refresh tokens. Keep access tokens very short so natural expiration handles most cases.

Frequently Asked Questions

Why not one token with long expiration?
A stolen long-lived token gives access for days. Two-token pattern limits access token exposure to 15 minutes. Refresh token is stored more securely and sent less often. This is the OAuth2 standard approach.
Symmetric or asymmetric signing?
Asymmetric with RS256 or ES256 for production. Private key stays on auth server. Public keys distributed for verification. API servers verify without ability to create tokens.
Can JWTs replace sessions entirely?
For APIs serving mobile and SPAs yes. For traditional server-rendered apps sessions are simpler. JWTs add complexity around refresh and revocation that sessions handle naturally. Use JWTs for stateless multi-service auth. Use sessions for monoliths.
How to handle JWT size?
Minimal JWTs are 200-300 bytes. Adding too many claims pushes to 1-2KB sent with every request. Keep payload minimal. Store user details server-side and look up by ID from the token.
banditz

Research Bug bounty at javahack team

Freeland Reseacrh Bug Bounty

View all articles →