JWT Authentication Best Practices: Access Tokens, Refresh Tokens, and Claims

Your JWT token is 2KB and expires in 24 hours. Both choices make your auth system less secure than you think. The 2KB size means you're sending sensitive data in every API request header. The 24-hour expiry means a stolen token gives an attacker a full day of valid access. Here's how to fix both.

Token Size: What Goes in a JWT

A JWT is Base64-encoded JSON — nothing is encrypted by default. Anything in the payload is readable by anyone who intercepts the token (or just decodes it in a browser). The JWT Decoder demonstrates this: paste any JWT and the full payload is visible without needing the signing secret.

What belongs in JWT claims:

  • sub — user ID (string or UUID, not a sequential integer)
  • iss — issuer (your API domain)
  • aud — audience (your app ID, to prevent tokens from one service being used at another)
  • exp — expiration time (Unix timestamp)
  • iat — issued at time
  • jti — unique token ID (for revocation lists)
  • Role or permission identifiers (e.g., "roles": ["user"])

What does not belong in JWT claims:

  • Email addresses (unnecessary — you have the user ID)
  • Names, profile data, preferences
  • Passwords (obvious, but worth stating)
  • Sensitive identifiers like SSN or payment data
  • Anything that shouldn't be visible if the token is intercepted

The goal is to keep the JWT under 300 bytes. A token with sub, iss, aud, exp, iat, jti, and a single role comes to about 250-300 bytes. A 2KB token means you're storing data in the JWT that belongs in your database.

Access Tokens vs. Refresh Tokens

Single-token auth with 24-hour expiry is a common pattern — and a security liability. The correct pattern is two-token:

Access token: 15 minutes

  • Short-lived JWT sent in the Authorization: Bearer header
  • Stateless — no database lookup on each request
  • If compromised: attacker has access for at most 15 minutes

Refresh token: 7-30 days

  • Long-lived opaque token (not a JWT — a random string stored in your database)
  • Sent in an httpOnly cookie, not accessible to JavaScript
  • Used only to get a new access token from the auth endpoint
  • Rotated on every use (refresh rotation): when used, the old token is invalidated and a new one is issued

The attack surface difference: with a 24-hour access token, a stolen token (via XSS, network interception, or a compromised log file) gives an attacker access for up to 24 hours. With a 15-minute access token, the window is 15 minutes. The refresh token handles seamless re-authentication without requiring the user to log in again.

// Access token payload (small, short-lived)
{
  "sub": "usr_01h8xjkqzm",
  "iss": "https://api.yourdomain.com",
  "aud": "yourapp",
  "exp": 1711234500,  // 15 minutes from now
  "iat": 1711233600,
  "jti": "jwt_01h9abc123",
  "roles": ["user"]
}

HS256 vs. RS256

HS256 (HMAC-SHA256): Symmetric — the same secret key is used to sign and verify tokens. Simple to implement; all services that verify tokens need a copy of the secret.

RS256 (RSA-SHA256): Asymmetric — a private key signs tokens, a public key verifies them. Any service can verify tokens without accessing the private key.

When to use each:

Use HS256 when: you have a single service (monolith) and the signing/verification happens in the same codebase. Simple, fast, minimal setup.

Use RS256 when: you have multiple services that need to verify tokens, or you want to publish a public key endpoint (JWKS). Services can download the public key from /.well-known/jwks.json and verify tokens independently without a shared secret.

For most early-stage APIs: HS256 with a 256-bit random secret is fine. The secret should be generated with a cryptographically secure random function (crypto.randomBytes(32).toString('hex') in Node.js) and stored in a secrets manager, not in an environment variable in your repo.

The Algorithm Confusion Attack

One of the most exploited JWT vulnerabilities: the alg field in the JWT header is attacker-controlled.

Some libraries verify tokens using whatever algorithm the token claims to use. If you set your server to verify with HS256, but an attacker crafts a token with "alg": "none" in the header, some libraries skip verification entirely.

Fix: Always explicitly specify the expected algorithm when verifying:

// Node.js (jsonwebtoken library)
jwt.verify(token, secret, { algorithms: ['HS256'] });

// Never do this:
jwt.verify(token, secret); // reads algorithm from token header

The algorithms option tells the library to reject any token that doesn't use the specified algorithm, regardless of what the token header claims.

Token Storage

Access tokens: Store in memory (JavaScript variable). Not in localStorage (XSS-accessible) and not in sessionStorage (XSS-accessible and cleared on tab close).

Refresh tokens: Store in httpOnly, Secure, SameSite=Strict cookies. The httpOnly flag prevents JavaScript from reading the cookie — it can only be sent in HTTP requests, not accessed by document.cookie.

The tradeoff: in-memory access tokens disappear on page refresh, so your frontend needs to silently request a new access token using the refresh cookie on load. This is a standard pattern in SPA auth flows.

Refresh Token Rotation and Reuse Detection

When a refresh token is used to issue a new access token, it should be immediately invalidated and replaced with a new refresh token. This is refresh token rotation. If an attacker steals a refresh token and tries to use it after the legitimate user has already rotated it, the server detects that an already-used token is being reused — a sign of compromise.

On detecting reuse, the correct response is to invalidate the entire token family (all refresh tokens for that user session) and force re-authentication. This limits the attack window: the attacker either uses the stolen token before the legitimate user does (in which case the legitimate user's next request detects the compromise) or after (in which case the token is already invalid).

Without rotation, a stolen refresh token is valid for the entire refresh token lifetime — potentially 30 days. With rotation and reuse detection, the attack window is limited to the time between token theft and next legitimate use.

JWT Expiry and Clock Skew

The exp claim is a Unix timestamp (seconds since epoch). A JWT is valid until exp is in the past. Most libraries include a leeway parameter (typically 60 seconds) to handle clock drift between servers — if your issuer and verifier clocks are more than ~60 seconds apart, tokens will appear expired or not-yet-valid.

For distributed systems where services run on different servers, ensure server clocks are synchronized with NTP (Network Time Protocol). AWS, GCP, and Azure VMs do this automatically; on-premises servers may not.

Set access token expiry to a specific timestamp, not a duration, to avoid confusion:

const payload = {
  sub: userId,
  exp: Math.floor(Date.now() / 1000) + (15 * 60), // 15 minutes from now
  iat: Math.floor(Date.now() / 1000)
};

Use the JWT Decoder to inspect your current tokens and verify that sensitive data is not in the payload.

JWT Decoder

Decode any JWT token to inspect its header, payload, and claims without needing the secret key.

Try this tool →