What is a JWT? How to decode and verify JSON Web Tokens
JWT tokens are everywhere in modern web auth — but what's actually inside them? Learn to decode, read, and verify JWTs instantly without installing anything.

This article is currently only available in English. A ภาษาไทย translation is coming soon.

If you've worked with a modern web API for more than a week, you've seen a string like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
That's a JSON Web Token — a JWT. It looks like gibberish, but every part is readable, structured, and carries information your application depends on to authenticate users, authorise requests, and maintain sessions.
This guide explains what JWTs are, exactly what's inside them, how to read them without any tools, how to verify them properly, and the security mistakes that bite teams who skip the details.
What is a JWT?
A JSON Web Token is a compact, URL-safe way to represent claims between two parties. It's formally defined in RFC 7519, published by the Internet Engineering Task Force (IETF) in 2015. JWTs are a core piece of how OAuth 2.0 and OpenID Connect work — two of the most widely deployed authentication and authorization protocols on the web.
Despite the name, a JWT itself isn't a JSON object. It's three Base64URL-encoded chunks separated by dots:
[Header].[Payload].[Signature]
Each chunk is independently decodable. The first two are just Base64URL-encoded JSON objects. The third is a cryptographic signature that ties the first two together and proves they haven't been tampered with.
The three parts in detail
Part 1: The Header
The header is a small JSON object that describes the token itself — what type it is and how it's signed. Decoded, it typically looks like:
{
"alg": "HS256",
"typ": "JWT"
}
alg tells any consumer of the token which signing algorithm was used. This matters at verification time: your code needs to know which algorithm to use to check the signature. Common values:
| Algorithm | Type | Notes |
|---|---|---|
HS256 |
HMAC + SHA-256 (symmetric) | Single shared secret. Simple, common in internal services. |
HS384 |
HMAC + SHA-384 (symmetric) | Larger digest, same symmetric model |
RS256 |
RSA + SHA-256 (asymmetric) | Private key signs; public key verifies. Good for third-party consumers. |
ES256 |
ECDSA + P-256 (asymmetric) | Shorter signatures than RSA, faster verification |
PS256 |
RSASSA-PSS + SHA-256 | Probabilistic RSA variant, recommended over RS256 in some security contexts |
none |
No signature | Never use in production — see security section below |
HS256 is the most common algorithm in internal services because it's simple — one shared secret, one library call. RS256 and ES256 are better when the token will be verified by a third party who shouldn't know the signing key (for example, a partner service validating tokens issued by your auth server).
Part 2: The Payload
The payload is where the actual data lives. It's a JSON object containing claims — statements about the subject (usually a user) and metadata about the token itself. Decoded:
{
"sub": "usr_1234567890",
"email": "john@example.com",
"role": "admin",
"iss": "https://auth.myapp.com",
"aud": "https://api.myapp.com",
"iat": 1716239022,
"exp": 1716242622,
"jti": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
RFC 7519 defines a set of "registered claims" — standardised keys with agreed-upon semantics:
| Claim | Name | Meaning |
|---|---|---|
iss |
Issuer | Who created the token. Usually the auth server's URL. |
sub |
Subject | Who the token is about. Usually a user ID. |
aud |
Audience | Who the token is intended for. Your API should reject tokens not addressed to it. |
exp |
Expiration | Unix timestamp (seconds). Token is invalid at or after this time. |
nbf |
Not Before | Token is invalid before this timestamp. |
iat |
Issued At | When the token was created. Used to calculate age. |
jti |
JWT ID | A unique identifier for this token. Used to prevent replay attacks. |
Everything beyond these is a "private claim" — application-specific data. Common examples: email, name, role, permissions, tenant_id, org_id. There's no schema enforcement at the JWT level — any key-value pair is valid.
Important: the payload is encoded, not encrypted. Base64URL encoding is trivially reversible by anyone who has the token. Treat JWT payloads as visible to anyone who can intercept the token. Don't put passwords, full card numbers, or other genuinely secret data in them.
Part 3: The Signature
The signature is what makes JWTs trustworthy — when verified. For HS256, the signature is computed as:
HMAC-SHA256(
base64url(header) + "." + base64url(payload),
secret_key
)
The signing input is exactly the first two segments of the token joined by a dot. If anyone modifies even a single character in the header or payload — changing "role":"user" to "role":"admin", or extending the exp timestamp — the computed signature won't match the one in the token, and a properly implemented verifier will reject it.
For asymmetric algorithms like RS256, the server signs with a private key and verifiers use the corresponding public key. The public key can be shared openly (often via a JWKS endpoint at /.well-known/jwks.json) without compromising security, since you can't derive the private key from the public one.
Decoding vs verifying: the most important distinction in JWT security
This is where most security misunderstandings come from.
Decoding means extracting the JSON from the Base64URL encoding. Anyone can do this — no key, no secret. Your browser console can decode a JWT right now:
const [header, payload] = token.split('.');
JSON.parse(atob(header.replace(/-/g, '+').replace(/_/g, '/')));
JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
Verifying means checking the signature against the expected signing key. This proves two things: (1) the token was issued by whoever holds the signing key, and (2) the header and payload haven't been modified since signing.
Decoding alone provides zero security guarantee. A decoded JWT tells you what the token claims. It tells you nothing about whether those claims are authentic or whether the token came from a trusted source.
In production code: never use jwt.decode() in a security-sensitive code path. Always use jwt.verify() — or the equivalent in your language's library — which decodes and validates the signature.
How to decode a JWT in your browser (no installation needed)
Use the Stax JWT Decoder:
- Paste the token into the input field
- The decoder instantly shows the decoded header, payload (as formatted JSON), and signature segment
- All timestamp fields (
exp,iat,nbf) are rendered as human-readable dates — no manual Unix timestamp conversion - Expiration status is shown: valid, expired, or not-yet-valid
- Structural errors (wrong number of segments, malformed Base64) are flagged immediately
All processing happens entirely in your browser. The token is never sent to any server — important when debugging tokens that contain user IDs, session data, or role assignments. You can also use it offline once the page is loaded.
Common debugging scenarios
"My API returns 401 even with a token that looks valid"
Decode the token and check each of these in sequence:
exp— is the token expired? Even by a few seconds? Clock skew between client and server can cause this.aud— does the audience claim match what your API expects? Many libraries reject tokens with a mismatchedaudsilently.iss— does the issuer match your auth server's configured issuer URL? Trailing slash differences (https://auth.myapp.comvshttps://auth.myapp.com/) can cause mismatches.nbf— is the "not before" time in the future? This is rare but happens when server clocks are out of sync.
Any one of these mismatches causes most JWT libraries to silently return a 401 or 403.
"My token works in development but not in production"
Check whether you're using different signing secrets in each environment. Also check the aud and iss claims — many identity providers issue environment-specific tokens. A token issued for https://dev.myapp.com won't validate against an audience configured for https://myapp.com.
"My JWT is very large — what's in it?"
Some identity providers (Okta, Auth0, Azure AD, Keycloak, AWS Cognito) embed dozens of custom claims: group memberships, permissions, tenant IDs, feature flags, UI settings. This is called "stuffing" the token.
Decoding it shows you exactly what's there. If the token size is causing issues (header size limits in load balancers are typically around 8 KB), you may need to switch to a reference token model — the JWT contains only an ID, and the API calls a separate endpoint to fetch full claims.
"Why does the token expire after only 15 minutes?"
Check exp minus iat — that's the token lifetime in seconds. Access tokens are intentionally short-lived in most modern auth setups: roughly 900–3600 seconds (15 minutes to 1 hour) is typical, though the exact value varies significantly by provider and configuration. Short-lived access tokens reduce the window of exposure if a token is stolen. The refresh token pattern handles re-issuance without forcing the user to log in again.
"I need to check if a token was tampered with"
In a browser or test context: paste the token into the JWT Decoder and compare the claims against what you expect. In production code: verify the signature — if it passes, the token hasn't been modified.
Security gotchas every developer should know
1. The alg: none attack
Some older JWT libraries accepted tokens with "alg": "none" in the header and skipped signature verification entirely — accepting any claims as valid. Auth0's security research team documented how this vulnerability affected multiple widely-used libraries before 2015, including popular Python, PHP, Ruby, and Java implementations.
Mitigation: always configure your JWT library to explicitly reject none and to only accept the algorithm(s) you expect. Never let the algorithm be determined solely by the header.
2. Algorithm confusion attacks
A subtler version of the same problem: some libraries that support both HS256 and RS256 could be tricked into verifying an RS256-signed token as HS256, using the public key as the HMAC secret. Since the public key is... public... an attacker who knows it could forge tokens.
Mitigation: configure your verifier to accept exactly one algorithm. Don't let the algorithm be negotiable.
3. Weak HMAC secrets
If you're using HS256, your secret is the only thing standing between a valid token and a forged one. A secret like "secret", "password", or anything under ~32 bytes of entropy can be brute-forced offline against intercepted tokens in minutes.
Use a randomly generated secret of at least 32 bytes (256 bits). In most languages: crypto.randomBytes(32).toString('hex') (Node.js) or secrets.token_hex(32) (Python).
4. Storing tokens in localStorage
localStorage is accessible to any JavaScript running on the page. A single XSS vulnerability — a stray eval(), an unescaped user input reflected in the DOM — can exfiltrate every token stored there.
HttpOnly cookies aren't accessible to JavaScript at all. For session tokens, prefer HttpOnly, Secure, SameSite=Strict cookies. For short-lived access tokens in SPAs, keeping them in memory (module-level variable, not localStorage) is a reasonable middle ground — they're lost on page refresh but can't be stolen by XSS.
5. Not validating claims
Signature verification confirms the token wasn't tampered with. It doesn't verify that the claims are appropriate for the current request. Always check aud, iss, and any custom claims (role, permissions) as part of authorization, not just authentication.
JWT vs session cookies: when to use which
JWTs aren't always the right choice. Sessions backed by server-side storage (Redis, database) are often simpler and easier to revoke.
| Scenario | JWT | Server session |
|---|---|---|
| Stateless microservices | ✅ Good fit | ❌ Requires shared session store |
| Need instant revocation | ❌ Hard (requires denylist) | ✅ Delete the session record |
| Third-party API consumers | ✅ Good fit (JWKS endpoint) | ❌ Awkward |
| Simple monolithic app | ❌ Overkill | ✅ Simpler |
| Mobile clients | ✅ Common pattern | ✅ Also works |
JWTs are stateless by design — the server doesn't need to store anything to verify a valid token. The downside: there's no native way to revoke a token before it expires. Token revocation in JWT systems requires maintaining a denylist of revoked jti values — which reintroduces state.
Harshil writes about privacy-first tools, developer productivity, and the trade-offs between browser-based and uploaded utilities.
Sources & methodology
- RFC 7519 — JSON Web Token — IETF, 2015. Standard definition, registered claims table, and Base64URL encoding specification
- Critical vulnerabilities in JSON Web Token libraries — Auth0 Security, 2015. Documentation of the
alg:noneattack and algorithm confusion vulnerabilities affecting multiple major JWT libraries - MDN Web Docs — HTTP Authorization — Bearer token usage in HTTP headers
- Token lifetime ranges described as "typical" reflect common industry defaults; actual values vary by provider and configuration. Always check your auth provider's documentation.
Last reviewed: 2026-05-14. Always verify JWTs server-side in production — browser-side decoding is for inspection only.

Harshil
Developer & Founder, stax.tools
Harshil is the developer behind stax.tools, building privacy-first tools that run entirely in your browser.
More by Harshil →Found this useful?
Browse 235+ free privacy-first tools — no login, no uploads, instant results.