StackPractices
intermediate By Mathias Paulenko

JWT Authentication

Sign, verify, and refresh JWTs in Python, JavaScript, and Java — access/refresh flows, RS256 key pairs, and token revocation done right.

Overview

JSON Web Tokens (JWT) remain the default way to do stateless authentication in modern APIs. A JWT is a compact, URL-safe string that carries signed claims — such as user identity and expiration time — between a client and a server.

Below: how to sign, verify, and refresh JWTs safely in Python, JavaScript, and Java — including the refresh-flow and revocation pieces most tutorials skip.

When to Use

Use JWTs when:

  • Building a stateless REST API where sessions shouldn’t be stored server-side
  • Authenticating microservices that call each other internally
  • Issuing short-lived access tokens with separate long-lived refresh tokens
  • Adding SSO or third-party login (OAuth2 / OpenID Connect)

Avoid JWTs when:

  • You need immediate server-side token revocation (use sessions + a token blocklist instead)
  • The payload is very large (JWTs are sent on every request)
  • You aren’t prepared to rotate signing keys securely

Solution

Python (PyJWT)

import jwt
from datetime import datetime, timedelta, UTC

SECRET = "your-256-bit-secret"  # store in env, never in code
ALGORITHM = "HS256"


def create_token(user_id: str, expires_minutes: int = 15) -> str:
    payload = {
        "sub": user_id,
        "iat": datetime.now(UTC),
        "exp": datetime.now(UTC) + timedelta(minutes=expires_minutes),
    }
    return jwt.encode(payload, SECRET, algorithm=ALGORITHM)


def verify_token(token: str) -> dict:
    try:
        return jwt.decode(token, SECRET, algorithms=[ALGORITHM])
    except jwt.ExpiredSignatureError:
        raise ValueError("Token has expired")
    except jwt.InvalidTokenError:
        raise ValueError("Invalid token")


# Usage
token = create_token("user-123")
claims = verify_token(token)
print(claims["sub"])  # user-123

JavaScript (jsonwebtoken)

const jwt = require('jsonwebtoken');

const SECRET = process.env.JWT_SECRET; // 256-bit secret from env

function createToken(userId, expiresIn = '15m') {
  return jwt.sign({ sub: userId }, SECRET, { expiresIn });
}

function verifyToken(token) {
  try {
    return jwt.verify(token, SECRET);
  } catch (err) {
    if (err.name === 'TokenExpiredError') throw new Error('Token has expired');
    throw new Error('Invalid token');
  }
}

// Usage
const token = createToken('user-123');
const claims = verifyToken(token);
console.log(claims.sub); // user-123

Java (JJWT 0.12+)

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;

public class JwtUtil {
    private static final SecretKey KEY =
        Keys.hmacShaKeyFor(System.getenv("JWT_SECRET").getBytes(StandardCharsets.UTF_8));
    private static final long ACCESS_TTL = 15 * 60 * 1000L; // 15 min

    public String createToken(String userId) {
        Date now = new Date();
        return Jwts.builder()
            .subject(userId)
            .issuedAt(now)
            .expiration(new Date(now.getTime() + ACCESS_TTL))
            .signWith(KEY)              // HS256 inferred from the key
            .compact();
    }

    public Claims verifyToken(String token) {
        try {
            return Jwts.parser()
                .verifyWith(KEY)
                .build()
                .parseSignedClaims(token)
                .getPayload();
        } catch (ExpiredJwtException e) {
            throw new IllegalArgumentException("Token has expired");
        } catch (JwtException e) {
            throw new IllegalArgumentException("Invalid token");
        }
    }
}

Explanation

A JWT has three parts separated by dots: header.payload.signature.

  • Header: tells the verifier which algorithm (alg) produced the signature and the token type (typ).
  • Payload: the claim set — who the token is about (sub), when it was issued (iat), and when it dies (exp).
  • Signature: the integrity check. You get it by signing base64(header) + "." + base64(payload) with your secret key — one flipped bit in the payload and verification fails.

Security notes:

  • Always use HTTPS in production — JWTs are bearer tokens; intercepting one is game over.
  • Store the signing secret in an environment variable or a secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.).
  • Prefer short expiration times (5–15 minutes) and issue refresh tokens for longer sessions.
  • Use HS256 only when a single service signs and verifies. Once a second service needs to verify, switch to asymmetric RS256 with a public/private key pair.

Variants

Refresh Token Flow

Access tokens stay short-lived — 5 to 15 minutes is the usual range. When they expire, the client sends the refresh token to get a new access token without re-authenticating.

sequenceDiagram diagram: participant C as Client
import jwt
import secrets
from datetime import datetime, timedelta, UTC

SECRET = "your-256-bit-secret"
ALGORITHM = "HS256"
REFRESH_TTL_DAYS = 7

# Store refresh tokens in a database or Redis keyed by user ID
refresh_store = {}  # In production, use Redis or a database


def create_access_token(user_id: str) -> str:
    payload = {
        "sub": user_id,
        "type": "access",
        "iat": datetime.now(UTC),
        "exp": datetime.now(UTC) + timedelta(minutes=15),
    }
    return jwt.encode(payload, SECRET, algorithm=ALGORITHM)


def create_refresh_token(user_id: str) -> str:
    token_id = secrets.token_urlsafe(32)
    payload = {
        "sub": user_id,
        "type": "refresh",
        "jti": token_id,
        "iat": datetime.now(UTC),
        "exp": datetime.now(UTC) + timedelta(days=REFRESH_TTL_DAYS),
    }
    token = jwt.encode(payload, SECRET, algorithm=ALGORITHM)
    refresh_store[token_id] = user_id
    return token


def refresh_access_token(refresh_token: str) -> str:
    try:
        claims = jwt.decode(refresh_token, SECRET, algorithms=[ALGORITHM])
    except jwt.ExpiredSignatureError:
        raise ValueError("Refresh token has expired")
    except jwt.InvalidTokenError:
        raise ValueError("Invalid refresh token")

    if claims.get("type") != "refresh":
        raise ValueError("Not a refresh token")

    token_id = claims.get("jti")
    if token_id not in refresh_store:
        raise ValueError("Refresh token has been revoked")

    return create_access_token(claims["sub"])


def revoke_refresh_token(refresh_token: str) -> None:
    try:
        claims = jwt.decode(refresh_token, SECRET, algorithms=[ALGORITHM])
    except jwt.InvalidTokenError:
        return

    token_id = claims.get("jti")
    if token_id and token_id in refresh_store:
        del refresh_store[token_id]


# Usage
access = create_access_token("user-123")
refresh = create_refresh_token("user-123")

# When the access token expires, get a new one
new_access = refresh_access_token(refresh)

# On logout, revoke the refresh token
revoke_refresh_token(refresh)

RS256 (Asymmetric) Variant

When more than one service needs to verify tokens, use RS256 so each service only needs the public key.

from cryptography.hazmat.primitives import serialization
import jwt
from datetime import datetime, timedelta, UTC

# Load private key (only on the auth service)
with open("private.pem", "rb") as f:
    private_key = serialization.load_pem_private_key(f, password=None)

# Load public key (on every service that verifies)
with open("public.pem", "rb") as f:
    public_key = serialization.load_pem_public_key(f)


def create_token_rs256(user_id: str) -> str:
    payload = {"sub": user_id, "exp": datetime.now(UTC) + timedelta(minutes=15)}
    return jwt.encode(payload, private_key, algorithm="RS256")


def verify_token_rs256(token: str) -> dict:
    return jwt.decode(token, public_key, algorithms=["RS256"])
const jwt = require('jsonwebtoken');
const fs = require('fs');

const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');

function createTokenRS256(userId) {
  return jwt.sign({ sub: userId }, privateKey, { algorithm: 'RS256', expiresIn: '15m' });
}

function verifyTokenRS256(token) {
  return jwt.verify(token, publicKey, { algorithms: ['RS256'] });
}

Token Blocklist with Redis

import redis
import jwt
from datetime import datetime, UTC

r = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)


def verify_with_blocklist(token: str, secret: str) -> dict:
    claims = jwt.decode(token, secret, algorithms=["HS256"])
    jti = claims.get("jti")
    if jti and r.exists(f"blocklist:{jti}"):
        raise ValueError("Token has been revoked")
    return claims


def revoke_token(token: str, secret: str) -> None:
    claims = jwt.decode(token, secret, algorithms=["HS256"])
    jti = claims.get("jti")
    if jti:
        # exp is a UNIX timestamp; keep the blocklist entry only until expiry
        ttl = int(claims["exp"] - datetime.now(UTC).timestamp())
        if ttl > 0:
            r.setex(f"blocklist:{jti}", ttl, "revoked")

Troubleshooting

  • Signature verification failed: wrong secret/key, or an algorithm mismatch — a verifier expecting RS256 rejecting an HS256 token. Whitelist the accepted algorithms explicitly on every verifier.
  • Token has expired on valid-looking tokens: clock skew between services. Allow a small leeway (30–60s) on jwt.decode/jwt.verify, and keep servers on NTP.
  • HTTP 431 Request Header Fields Too Large: the JWT grew too big for the request header budget (common when stuffing roles/claims). Trim claims or shorten the token.
  • Refresh token replay: a stolen refresh token lets an attacker mint access tokens forever. Rotate refresh tokens on every use and invalidate the old jti — the pattern in the refresh flow above is where to enforce it.

What Works

  • Rotate keys regularly: implement a key version (kid header claim) so you can rotate without invalidating all active tokens.
  • Use refresh tokens: store them in HttpOnly, Secure, SameSite=Strict cookies and keep access tokens in memory only — nothing readable from JS.
  • Validate the algorithm: pin the allowed list (algorithms=['HS256']) — otherwise a forged alg: none token sails through.
  • Never put secrets in the payload: JWTs are base64-encoded, not encrypted. Anyone can read the payload.
  • Log token IDs, not tokens: if you log authentication events, log jti (token ID) or sub, never the full token string.

Common Mistakes

  • Putting secrets in the JWT payload: anything sensitive is readable by whoever intercepts the token.
  • Skipping algorithm checks: accepting alg: none, or letting the token pick its own algorithm, is the classic JWT forgery.
  • Infinite-lived tokens: tokens without exp are dangerous — if leaked, they’re valid forever.
  • Using weak secrets: a short secret puts brute-forcing the HMAC signature within reach.
  • Trusting the client to delete tokens: always enforce expiration server-side; clients can be compromised.

See Also

Frequently Asked Questions

Should I store JWTs in localStorage or cookies?

Access tokens live in memory (a variable). Refresh tokens go in HttpOnly, Secure, SameSite=Strict cookies so XSS can't read them.

How do I revoke a JWT before it expires?

Maintain a token blocklist (e.g., Redis with TTL matching token expiry) and check it on every verification. Alternatively, keep sessions server-side.

What's the difference between HS256 and RS256?

HS256 is symmetric — the same secret signs and verifies, so anyone holding it can also forge. RS256 splits the roles: a private key signs, and any service holding the public key can only verify. Reach for RS256 once two or more services verify tokens — sign with the private key, verify with the public one.

How long should a JWT access token last?

Keep access tokens at 5–15 minutes; refresh tokens usually sit in the 7–30 day range depending on how strict your security posture is. A short-lived access token means a leaked one is useless within minutes.

Can I use JWTs with WebSockets?

Yes. Pass the JWT as a query parameter during the WebSocket handshake (wss://example.com/ws?token=...) or in the Sec-WebSocket-Protocol header. Validate the token before accepting the connection — the initial validation is sufficient, don't resend tokens in regular messages.

What claims should I include in a JWT?

Standard claims: sub (user ID), iat (issued at), exp (expiration), jti (unique token ID for revocation). Optional: iss (issuer), aud (audience), roles, scope. Avoid PII or secrets in the payload — it's base64-encoded, not encrypted.

How do I rotate signing keys without invalidating all tokens?

Use the kid (key ID) header claim. During rotation, accept tokens signed with both the old and new keys for a transition period. Once all old tokens have expired, remove the old key. Publish public keys via a JWKS endpoint for RS256.