JWT — JSON Web Token is the most widely used format for stateless authentication and authorisation on the web. Understanding how to decode and validate one correctly is foundational to building secure APIs and debugging authentication issues. Decoding is trivial; verification is where security lives.
Overview
A JWT is a compact, URL-safe token that represents a set of claims as a JSON object. It is defined in RFC 7519. JWTs are used in OAuth 2.0 flows, OpenID Connect, and direct API authentication — anywhere a server needs to issue a verifiable credential that a client can present on future requests.
The key distinction this guide will repeat: decoding is not verifying. Anyone can decode a JWT. Only the party with the correct secret or public key can verify that the token is authentic and unmodified. Use the JWT Validator tool to decode and verify JWT tokens online.
What You Need
- A JWT token string (three dot-separated Base64URL segments)
- For verification: the signing secret (HS256) or the issuer's public key / JWKS endpoint (RS256/ES256)
- Optionally: a JWT debugger or the JWT Validator tool for instant decoding
Step 1: Understand JWT Structure
A JWT is always three Base64URL-encoded strings joined by dots:
header.payload.signature
Example token (abbreviated):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Each of the three segments can be independently decoded with Base64URL decoding. The first two are JSON objects. The third is the raw cryptographic signature bytes.
Base64URL vs Base64: JWT uses Base64URL, which replaces + with - and / with _, and drops trailing = padding. This makes the token safe to embed directly in URLs, Authorization headers, and cookies without percent-encoding.
Step 2: Decode the Header
Base64URL-decode the first segment (before the first dot) to get the header JSON:
{
"alg": "HS256",
"typ": "JWT"
}
The header contains:
alg— the signing algorithm. Common values:HS256— HMAC-SHA256 with a shared secretHS384,HS512— HMAC with SHA-384 and SHA-512RS256— RSA signature with SHA-256 (asymmetric)ES256— ECDSA with P-256 curve and SHA-256 (asymmetric)none— dangerous; reject any token with this value
typ— token type; alwaysJWTfor standard tokenskid— key ID (optional); used to select the correct key from a JWKS when multiple keys are in rotation
The algorithm in the header tells you what you need to verify the signature in Step 4. If the alg field is none, reject the token immediately — this is a known attack vector.
Step 3: Decode the Payload
Base64URL-decode the second segment (between the two dots) to get the payload JSON:
{
"iss": "https://auth.example.com",
"sub": "user_abc123",
"aud": "api.example.com",
"exp": 1751000000,
"nbf": 1750996400,
"iat": 1750996400,
"jti": "a5f2c3d1-8b4e-4c9f-a2b1-3e7d5f9c1a0b",
"email": "user@example.com",
"role": "editor"
}
Standard claims (defined in RFC 7519):
| Claim | Name | Format | Purpose |
|---|---|---|---|
iss |
Issuer | String (URL) | Who issued this token |
sub |
Subject | String | Who this token is about (user ID) |
aud |
Audience | String or array | Intended recipient(s) of this token |
exp |
Expiration | Unix timestamp | Token is invalid after this time |
nbf |
Not Before | Unix timestamp | Token is invalid before this time |
iat |
Issued At | Unix timestamp | When the token was created |
jti |
JWT ID | String | Unique ID; used to prevent replay attacks |
Any additional fields beyond these (like email, role, permissions) are custom claims defined by the application.
Reading exp: Convert the Unix timestamp to a human-readable date. exp: 1751000000 corresponds to a specific UTC datetime. If the current time is past exp, the token is expired and must be rejected — even if the signature is valid.
Step 4: Verify the Signature
This is the most critical step. Decoding the header and payload is trivial — anyone can do it. The signature is what proves the token has not been tampered with.
For HS256 (symmetric):
The signature is computed as:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
To verify: recompute the same HMAC using the shared secret and compare it to the signature in the token. If they match, the token is authentic. The shared secret must be kept confidential — anyone with the secret can both verify and forge tokens.
For RS256 (asymmetric):
The issuer signs with their RSA private key:
RSA_SHA256_sign(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
privateKey
)
Verification uses the corresponding public key:
RSA_SHA256_verify(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
publicKey,
signature
)
The public key is typically published at the issuer's JWKS endpoint (/.well-known/jwks.json). This allows any service to verify tokens without having access to the private key — and without the ability to forge new ones.
Never skip signature verification. A JWT that has been decoded but not verified is an untrustworthy document — payload claims can be anything an attacker chooses to put there.
Step 5: Check Expiry and Other Claims
After verifying the signature, validate the claims:
1. Check exp (expiration):
import time
if payload["exp"] < time.time():
raise Exception("Token is expired")
An expired token must be rejected even if the signature is valid. Clock skew between services is common — a tolerance of ±30 seconds is acceptable; more than 60 seconds is not.
2. Check nbf (not before):
If present, the token must not be accepted before the nbf timestamp. A token with nbf set 5 minutes in the future should be rejected now.
3. Check iss (issuer):
Verify the issuer matches the expected token source. If your auth server is https://auth.example.com, reject any token where iss is different — this prevents tokens issued by other systems from being accepted by your API.
4. Check aud (audience):
If present, the aud claim must match your service's identifier. A token issued for api.example.com should not be accepted by dashboard.example.com — this prevents token misuse across services in the same organisation.
5. Check jti for replay prevention (if needed):
For high-security flows, maintain a short-lived cache of used jti values. Reject any token whose jti has already been seen — this prevents a stolen token from being replayed within its validity window.
Common Mistakes to Avoid
Confusing decoding with verification: Decoding shows you the payload contents. Verification proves those contents are authentic. Libraries like jsonwebtoken (Node.js) require you to call jwt.verify(token, secret), not jwt.decode(token) — the decode function skips signature verification entirely. Use the verify function in production.
Storing JWT in localStorage: JavaScript running on your page (including third-party scripts) can read localStorage, making stored tokens vulnerable to XSS. Store access tokens in memory and use httpOnly, Secure, SameSite cookies for tokens that need to persist across page reloads.
Not checking exp: Some JWT implementations decode the token and use the claims without explicitly checking expiry. This means a token stolen 2 hours ago still works. Always check exp as the first claim validation after signature verification.
Accepting alg:none attacks: The alg field in the header is attacker-controlled before verification. A vulnerable library that trusts alg:none accepts any unsigned token. Use a strict algorithm allowlist and reject any token whose alg is not on it.
Putting sensitive data in the payload: The payload is encoded, not encrypted. atob(token.split('.')[1]) in a browser console reveals the payload in plain text. Never put passwords, credit card numbers, SSNs, or anything sensitive in JWT claims. Use opaque tokens or JWE (JSON Web Encryption) for sensitive payloads.
Formula & Methodology
JWT encoding uses Base64URL (RFC 4648 §5), which maps 6-bit groups to URL-safe characters. The formula for each segment:
Base64URL(JSON.stringify(header))
Base64URL(JSON.stringify(payload))
CryptoSign(segment1 + "." + segment2, key, alg)
The complete token is:
Base64URL(header) + "." + Base64URL(payload) + "." + Base64URL(signature)
For HS256, the signing input is exactly base64url_header + "." + base64url_payload — a precise ASCII string, no whitespace, no pretty-printing. Changing even one character in the payload (adding a space, changing a letter case) produces a completely different HMAC, making the token fail verification.
The security model of JWT relies on the impossibility of forging the signature without the key. For HS256 with a sufficiently long (≥ 256-bit) random secret, brute-forcing is computationally infeasible. For RS256, security depends on the RSA key length — use minimum 2048-bit keys, preferably 4096-bit for long-lived systems.
Use the JWT Validator to decode any JWT token instantly and inspect all three segments with formatted JSON output.