Security

JWT and token authentication: a practical security guide.

February 25, 2026 ยท11 min read ยท47Network Engineering

JWTs are everywhere, misused in about half those places, and the source of a disproportionate number of auth bugs. The core concept is sound โ€” a signed, self-contained token that lets a server verify a claim without a database lookup โ€” but the implementation details matter enormously. The wrong signing algorithm, tokens that never expire, JWTs stored in localStorage, missing audience validation: any of these turns a stateless auth system into a security liability. This post covers the structure, the common pitfalls, and the refresh token rotation pattern that makes JWTs actually safe to use in production.

JWT structure and what each part means

A JWT is three base64url-encoded sections separated by dots: header.payload.signature.

// Header โ€” algorithm and token type
{
  "alg": "RS256",    // Signing algorithm โ€” use RS256 or ES256, never HS256 in distributed systems
  "typ": "JWT"
}

// Payload โ€” claims about the subject
{
  "sub": "user-uuid-here",          // Subject: who this token represents
  "iss": "https://sso.example.com", // Issuer: who created this token
  "aud": "api.example.com",         // Audience: who this token is intended for
  "exp": 1740499200,                // Expiry: Unix timestamp (ALWAYS include this)
  "iat": 1740495600,                // Issued-at timestamp
  "jti": "unique-token-id",         // JWT ID: enables revocation tracking
  "roles": ["user"],                // Custom claims: application-specific data
  "email": "alice@example.com"
}

// Signature โ€” cryptographic proof the header+payload haven't been tampered with
// For RS256: RSASSA-PKCS1-v1_5 signature using the issuer's private key
// Verified with the corresponding public key โ€” no secret sharing required

Algorithm choice: RS256, ES256, or HS256?

  • HS256 (HMAC-SHA256): symmetric โ€” the same secret signs and verifies. Every service that needs to verify tokens must know the secret. If any service is compromised, the entire signing secret is exposed and arbitrary tokens can be forged. Only acceptable for single-service, non-distributed systems.
  • RS256 (RSA-SHA256): asymmetric โ€” private key signs, public key verifies. The signing key never leaves the auth server. All other services verify with the public key, which can be published (e.g. at a JWKS endpoint). The standard choice for most production systems.
  • ES256 (ECDSA-SHA256): asymmetric like RS256 but produces much smaller signatures and is faster. Preferred over RS256 for new systems โ€” identical security model, better performance.

The alg: none vulnerability: some early JWT libraries accepted tokens with "alg": "none" and no signature as valid โ€” allowing anyone to forge arbitrary tokens by simply omitting the signature and setting alg to none. Always explicitly specify which algorithms your library accepts and reject everything else. Never trust the alg header โ€” your code specifies the algorithm, the header is untrusted input.

Short-lived access tokens with refresh token rotation

The fundamental tension with JWTs: they're stateless (good for scalability) but also irrevocable โ€” once issued, a JWT is valid until it expires. This is why access tokens must be short-lived (15 minutes is standard) and refresh tokens must rotate on every use:

// Token issuance at login
async function login(email, password) {
  const user = await verifyCredentials(email, password);

  // Short-lived access token โ€” 15 minutes
  const accessToken = await signJWT({
    sub: user.id,
    email: user.email,
    roles: user.roles,
    iss: 'https://auth.example.com',
    aud: 'api.example.com',
    exp: Math.floor(Date.now() / 1000) + 900,  // 15 min
    jti: crypto.randomUUID(),
  }, privateKey, 'ES256');

  // Long-lived refresh token โ€” stored server-side, rotated on each use
  const refreshToken = crypto.randomBytes(32).toString('hex');
  await db.refreshTokens.create({
    token: await hash(refreshToken),   // Store hash, not plaintext
    userId: user.id,
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
    family: crypto.randomUUID(),  // Token family for rotation detection
  });

  return { accessToken, refreshToken };
}

// Token refresh โ€” rotate the refresh token on every use
async function refreshAccessToken(refreshToken) {
  const stored = await db.refreshTokens.findByHash(await hash(refreshToken));

  if (!stored || stored.expiresAt < new Date()) {
    throw new Error('Invalid or expired refresh token');
  }

  // Refresh token rotation: invalidate the used token
  await db.refreshTokens.delete(stored.id);

  // Detect reuse: if this token was already used, the family is compromised
  if (stored.usedAt) {
    // Someone is replaying a used refresh token โ€” invalidate the entire family
    await db.refreshTokens.deleteFamily(stored.family);
    throw new Error('Refresh token reuse detected โ€” all sessions invalidated');
  }

  // Issue new tokens
  return login_internal(stored.userId);
}

Token storage: where to put JWTs on the client

  • HttpOnly cookies: the browser sends automatically, inaccessible to JavaScript โ€” eliminates XSS token theft. Vulnerable to CSRF, mitigated with SameSite=Strict or CSRF tokens. The recommended storage for web apps.
  • Memory (JS variable): not persisted, survives page navigation in SPAs. Secure from XSS, lost on refresh. Good for access tokens in SPAs when combined with HttpOnly refresh token cookies.
  • localStorage/sessionStorage: readable by any JavaScript on the page. A single XSS vulnerability anywhere on your domain exposes all stored tokens. Do not store JWTs here.

Token revocation when you need it

Sometimes you must invalidate a token before it expires โ€” user logs out, password changed, account suspended. The options, in order of simplicity:

  • Short expiry: 15-minute access tokens limit the revocation window to 15 minutes maximum. Often acceptable.
  • Revocation list (blocklist): store invalidated jti values in Redis with expiry matching the token expiry. Check on every request. Fast but requires a Redis read per API call.
  • Reference tokens: the token is an opaque ID that the resource server exchanges for claims at the auth server. Fully revocable, but eliminates the stateless benefit of JWTs. Appropriate for high-security operations.

In 47Network products, the 47ID/Keycloak-based SSO uses short-lived JWTs (15 minutes) issued by Keycloak, with refresh tokens managed server-side. Product APIs validate JWTs against Keycloak's published JWKS endpoint โ€” no secret sharing, and token introspection is only used for revocation on sensitive operations like vault access in PassVault.


โ† Back to Blog Passkeys & WebAuthn โ†’