← Back to Blog Security

JWT Security Complete Guide: Signing, Verification, Attacks & Best Practices (2026)

🔐 JWT Tools — 100% Client-Side, Your Token Never Leaves Your Browser

1. What is a JWT?

A JWT (JSON Web Token, pronounced "jot") is a compact, URL-safe token format defined in RFC 7519. It encodes a JSON payload — called claims — and optionally signs and/or encrypts it. The result is a string you can embed in HTTP headers, URLs, or cookies.

The dominant use case is stateless authentication: after a successful login, the server issues a JWT that encodes the user's identity and permissions. The client presents this token on every subsequent request. The server cryptographically verifies the token's signature without querying a database — this is what makes JWT "stateless."

JWTs are part of a broader family of standards collectively called JOSE (JSON Object Signing and Encryption):

  • JWS (JSON Web Signature, RFC 7515) — what most people call a "JWT": a signed token.
  • JWE (JSON Web Encryption, RFC 7516) — an encrypted token. Payload is confidential.
  • JWK (JSON Web Key, RFC 7517) — a JSON format for cryptographic keys.
  • JWA (JSON Web Algorithms, RFC 7518) — defines signing and encryption algorithms.

When engineers say "JWT," they almost always mean JWS — a signed-but-not-encrypted token. The payload is readable by anyone who holds the token.

2. JWT Structure: Header, Payload, Signature

A JWT consists of three base64url-encoded parts separated by dots: header.payload.signature. Base64url is a URL-safe variant of base64 (uses - and _ instead of + and /, and omits padding =).

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

# Header (base64url decoded):
{
  "alg": "RS256",   # signing algorithm
  "typ": "JWT"      # token type
}

# Payload (base64url decoded):
{
  "sub": "user-123",          # subject: who the token is about
  "iss": "https://auth.example.com", # issuer
  "aud": "https://api.example.com",  # audience
  "iat": 1750000000,          # issued at (Unix timestamp)
  "exp": 1750003600,          # expires at (iat + 1 hour)
  "jti": "uuid-v4-here",      # JWT ID (unique per token)
  "role": "admin"             # custom claim
}

# Signature (for RS256):
# RSA-SHA256(base64url(header) + "." + base64url(payload), privateKey)

Critical fact: the payload is only encoded, not encrypted. Base64url decoding is a reversible, public operation. Anyone who obtains your JWT can read every claim in the payload. Never include secrets, passwords, or sensitive PII in a JWT payload.

Use our JWT Debugger to paste any token and instantly see the decoded header and payload — entirely in your browser with zero network requests.

3. Standard JWT Claims (RFC 7519)

RFC 7519 defines a set of registered claim names with specific semantics. Libraries and frameworks understand and validate these automatically:

ClaimFull NameTypeDescription
issIssuerString / URIWho issued this token (your auth server URL)
subSubjectStringWho this token is about (user ID)
audAudienceString / ArrayWho this token is intended for (your API)
expExpiration TimeNumericDateToken invalid after this Unix timestamp
nbfNot BeforeNumericDateToken invalid before this Unix timestamp
iatIssued AtNumericDateWhen the token was created
jtiJWT IDStringUnique identifier for this token (for blocklists)

Audience Validation — The Underrated Defence

The aud claim is critically important and often skipped. If you have multiple APIs and one is compromised, an attacker could take a valid JWT issued for API A and replay it against API B — unless API B validates that the token's aud matches its own identifier. Always validate aud, iss, and exp on every request.

Use our JWT Expiry Checker to inspect exp, iat, iss, and aud claims without sending the token to any server.

4. Signing Algorithms: HS256 vs RS256 vs ES256

The alg claim in the JWT header names the algorithm used to sign the token. The choice of algorithm has major security implications.

AlgorithmTypeKeyUse case
HS256Symmetric (HMAC)Shared secret (same key signs and verifies)Single-service: auth server and API are the same codebase
HS384 / HS512Symmetric (HMAC)Shared secretSame as HS256, stronger hash
RS256Asymmetric (RSA)Private key signs; public key verifiesDistributed: multiple services verify without the signing secret
ES256Asymmetric (ECDSA)Private key signs; public key verifiesSame as RS256, smaller keys and signatures, faster
noneNoneNo signature❌ Never use — critical security vulnerability

HS256 — When It Is Appropriate

HMAC-SHA256 is fast and simple. It uses a single secret that must be shared between every system that signs or verifies tokens. This is fine when your auth server and API server are part of the same monolith or when you can securely distribute the secret to a small, trusted set of services.

The problem: every service that needs to verify tokens must have the secret. If one service is compromised, the attacker can forge tokens for all services. HS256 also has no key rotation mechanism — rotating the secret immediately invalidates all existing tokens.

RS256 / ES256 — Production Default

Asymmetric algorithms use a key pair: the auth server keeps the private key secret and signs tokens with it. Any service that needs to verify tokens gets the public key — which is safe to distribute widely. A compromised service that only has the public key cannot forge tokens.

Key rotation is clean: publish the new public key at your JWKS endpoint alongside the old one (with different kid values). Services can cache both and automatically handle tokens signed with either key.

Use our JWK Generator to generate RS256 or ES256 key pairs in JWK format, suitable for use in your auth server or JWKS endpoint.

5. The none Algorithm Attack

The none algorithm attack is one of the most famous JWT vulnerabilities, first documented around 2015. It exploits JWT libraries that treat the alg header claim as authoritative — meaning they use whatever algorithm the token itself claims, rather than what the server expects.

# Normal JWT header:
{ "alg": "RS256", "typ": "JWT" }

# Attacker-crafted header (none attack):
{ "alg": "none", "typ": "JWT" }

# The attacker constructs:
base64url({"alg":"none"}) + "." + base64url(modifiedPayload) + "."
# ^ note the empty signature after the final dot

# Vulnerable library code (DO NOT do this):
const decoded = jwt.verify(token, secret, {}); // no algorithm whitelist

# Secure library code:
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });

Why It Works (When It Works)

A compliant JWT library must support none as a valid algorithm for unsigned tokens. The bug is in servers that do not restrict which algorithms they accept. An attacker cannot exploit this against a server that says "I only accept RS256" — but many early implementations had no such restriction.

Fix

  • Always pass an explicit algorithms whitelist to your JWT verify function.
  • Never allow your verification code to accept alg: none.
  • Use a reputable, actively maintained JWT library — most have patched this by default.
  • Run our JWT Signature Verifier to test that your tokens have valid signatures.

6. Algorithm Confusion Attack (RS256 → HS256)

The algorithm confusion attack (also called key confusion) is subtler than the none attack, and more dangerous. It targets systems that accept both RS256 and HS256 for the same verification code path.

# RS256 key pair:
# Private key → used to SIGN (server only)
# Public key  → used to VERIFY (can be shared)

# Algorithm confusion attack:
# If a server accepts both RS256 and HS256,
# an attacker can take the PUBLIC KEY (which is not secret)
# and use it as the HMAC secret to sign a HS256 token.
# A confused server might verify HS256(token, publicKey) ✓

# Fix: pin the algorithm, never accept both asymmetric and symmetric
# for the same key material:
jwt.verify(token, rsaPublicKey, { algorithms: ['RS256'] }); // SAFE
jwt.verify(token, secret,       { algorithms: ['HS256'] }); // SAFE

# NEVER:
jwt.verify(token, publicKey, { algorithms: ['RS256', 'HS256'] }); // DANGEROUS

The Attack Explained

RS256 uses a public key for verification — by definition, this key is public and can be obtained by anyone. If a server accepts both RS256 and HS256, an attacker can:

  1. Obtain the server's public key (from the JWKS endpoint or by extracting it from an existing token).
  2. Craft a new payload with modified claims (elevated role, different user ID).
  3. Sign it with HS256 using the public key as the HMAC secret.
  4. Set alg: HS256 in the header.
  5. Submit the token. The confused server calls verify(token, publicKey, algorithms=['RS256','HS256']), which tries HS256 and succeeds.

Fix

  • Never mix asymmetric and symmetric algorithms in the same verification call.
  • Keep your algorithm config in a single place — a constant, not a dynamic value from a config file an attacker could influence.
  • If your API accepts RS256, only accept RS256.

7. Token Expiry Best Practices

The exp claim is your primary damage-control mechanism. A stolen JWT is valid until it expires — a shorter expiry means a smaller window of harm.

System TypeRecommended Access Token LifetimeNotes
Banking / fintech / healthcare5–10 minutesPair with very short refresh token (4 hours)
Standard SaaS product15–60 minutesBalance between UX and security
Internal tooling / low-risk APIUp to 8 hoursAcceptable if tokens are not exposed to browsers
Machine-to-machine (M2M)1 hourNo user session; rotate via client credentials flow

Clock Skew Tolerance

Distributed systems have clock drift. If your auth server's clock is 2 seconds ahead of your API server's clock, tokens that are "just expired" from the auth server's view are still valid from the API's view. Most JWT libraries accept a clockTolerance option (typically 30–60 seconds). Set it — but do not set it to 5 minutes (this defeats the purpose of short-lived tokens).

The nbf Claim — Preventing Replay Before Issuance

The nbf (not before) claim is the mirror of exp. It prevents a token from being accepted before a specific time. This is useful for issuing tokens that activate in the future (e.g., a download link that becomes valid in 30 seconds, giving the user time to initiate the download).

8. Refresh Token Pattern

Short-lived access tokens create a UX problem: users get logged out every 15 minutes. The solution is a refresh token — a long-lived, single-use (ideally) credential that the client uses to get a new access token without re-authenticating.

# Secure access + refresh token pattern

# 1. Login → server issues:
#    - Access token (RS256, 15-min exp) → returned in JSON body
#    - Refresh token (opaque or JWT) → set as httpOnly cookie

# 2. Client stores:
#    - Access token: in-memory JS variable (NOT localStorage)
#    - Refresh token: httpOnly cookie (browser manages it automatically)

# 3. API request:
Authorization: Bearer 

# 4. Access token expires → client calls /auth/refresh
#    - Browser automatically sends httpOnly cookie
#    - Server validates refresh token, issues new access token
#    - Server can rotate the refresh token (recommended)

# 5. Logout:
#    - Server invalidates refresh token (delete from DB)
#    - Client clears in-memory access token
#    - Server clears httpOnly cookie

Refresh Token Rotation

Each time the client uses a refresh token, the server issues a new refresh token and invalidates the old one. This means a stolen refresh token can only be used once before it becomes invalid. If an attacker uses a refresh token first, the next legitimate use by the real user will fail — alerting the system that the token was stolen.

Refresh Token Storage

Refresh tokens must be stored in httpOnly, Secure, SameSite=Strict cookies. This makes them inaccessible to JavaScript (XSS protection) and sends them only over HTTPS to the same origin (CSRF protection). Do not store refresh tokens in localStorage, sessionStorage, or JavaScript memory.

9. Where to Store JWTs in the Browser

Storage LocationXSS RiskCSRF RiskVerdict
httpOnly Cookie✅ None (JS can't read)⚠️ Mitigate with SameSite✅ Best for refresh tokens
In-Memory (JS var)⚠️ Cleared on tab close✅ None✅ Good for short-lived access tokens
localStorage❌ Readable by any XSS✅ None❌ Never for long-lived tokens
sessionStorage❌ Readable by any XSS✅ None❌ Same risk as localStorage
Regular Cookie❌ JS-readable⚠️ SameSite required❌ Use httpOnly instead

The Recommended Pattern

  • Access token: store in a JavaScript variable (in-memory). Send as Authorization: Bearer <token>. Lost on tab close, refreshed transparently via the refresh flow.
  • Refresh token: stored in an httpOnly, Secure, SameSite=Strict cookie. Your /auth/refresh endpoint accepts the cookie automatically.

10. How to Invalidate JWTs

Stateless JWTs are valid until exp. There is no server-side "session" to destroy. If you need to invalidate a token early (password change, account compromise, logout), you have four options:

  1. Short expiry — design tokens to be short-lived enough that waiting for natural expiry is acceptable. Works for most logout scenarios.
  2. Blocklist by JTI — include a unique jti (JWT ID) in every token. Maintain a server-side Redis blocklist. On every request, check if the jti is in the blocklist. Store only until exp — Redis TTL handles cleanup automatically.
  3. Version claim — include a version or tokenVersion claim in the JWT. Store the current valid version per user in your database. On every request, reject tokens with an outdated version. Incrementing the version instantly invalidates all tokens for that user.
  4. Reference tokens — issue an opaque (random) token. Store a mapping server-side. Every request queries the server. This is essentially the stateful session model — you lose the stateless benefit but gain instant revocation.

11. JWKS Endpoints and Key Rotation

JWKS (JSON Web Key Set) is a standard JSON format listing your public keys with their key IDs (kid). Your auth server exposes this at a well-known URL (e.g., https://auth.example.com/.well-known/jwks.json). APIs and third-party integrations fetch this endpoint to get the current public keys for verifying your JWTs.

{`// Example JWKS response:
{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "kid": "key-2026-v1",
      "alg": "RS256",
      "n": "0vx7agoebGcQSuu...",
      "e": "AQAB"
    }
  ]
}`}

Key Rotation Procedure

  1. Generate a new key pair (new kid).
  2. Add the new public key to your JWKS endpoint alongside the old one.
  3. Switch your auth server to sign new tokens with the new private key.
  4. Wait for all existing tokens signed with the old key to expire.
  5. Remove the old public key from the JWKS endpoint.

Use our JWK Generator to create properly formatted JWK key pairs for your JWKS endpoint.

12. Safely Debugging JWTs

Developers commonly paste JWTs into online tools like jwt.io to inspect them during debugging. This is a significant security risk in production environments: the token may contain user identity data, and pasting it into a third-party website logs it in their server access logs, your browser history, and potentially analytics pipelines.

All ZeroData Tools JWT tools are 100% client-side. When you paste a token into our JWT Debugger, JWT Expiry Checker, or JWT Signature Verifier, the token never leaves your browser. The page makes zero network requests with your token data.

For production debugging in CI/CD or on a server, you can also use the command line:

# Decode JWT payload without signature verification (for inspection only):
TOKEN="your.jwt.here"
echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool

# Or with jq:
echo $TOKEN | cut -d. -f2 | base64 -d | jq .

Frequently Asked Questions

What is a JWT token?
A JWT (JSON Web Token) is a compact, URL-safe token format defined in RFC 7519. It encodes a JSON payload (claims) and is cryptographically signed so the recipient can verify the payload has not been tampered with. JWTs are widely used for authentication and authorization: after login, the server issues a JWT the client presents on every request.
What signing algorithm should I use for JWTs?
Use RS256 (RSA + SHA-256) or ES256 (ECDSA + SHA-256) for production systems where multiple services verify tokens — you distribute only the public key, not the signing secret. Use HS256 only in single-service systems where the signing and verifying code are equally trusted. Never use the none algorithm.
How long should a JWT access token be valid?
Access tokens should be short-lived: 5–15 minutes for sensitive systems, up to 1 hour for low-risk APIs. Combine with longer-lived refresh tokens (1–30 days) stored in httpOnly cookies. Short lifetimes limit the damage window if a token is stolen.
What is the JWT 'none' algorithm attack?
The none attack exploits libraries that accept unsigned JWTs when alg is set to 'none'. An attacker modifies claims, sets alg to none, removes the signature, and the vulnerable server accepts it. Fix: explicitly whitelist accepted algorithms in your JWT library configuration and never allow alg: none.
Is the JWT payload encrypted?
No. The standard JWT (JWS) payload is only base64url-encoded, not encrypted — anyone who holds the token can decode and read the claims. Never put sensitive data in a JWT payload. Use JWE for confidentiality, or keep sensitive data server-side and reference it by a session/user ID in the JWT.
Where should I store JWTs in the browser?
Store refresh tokens in httpOnly, Secure, SameSite=Strict cookies — inaccessible to JavaScript. Store short-lived access tokens in a JavaScript variable (memory), not localStorage. localStorage is readable by any XSS payload and is a common target for credential theft attacks.
How do I invalidate a JWT before it expires?
Maintain a server-side blocklist (Redis) of invalidated JTI values, or use a per-user token version stored in your database. Check the blocklist or version on every API request. For most logout scenarios, short access token expiry (15 min) combined with immediate refresh token invalidation is sufficient.