← Back to Blog

Complete Guide to JWT Token Parsing & Verification

What is a JWT?

A JWT (JSON Web Token, RFC 7519) is an open standard that packs a set of claims into a JSON object and signs it so the receiver can prove the content was not modified in transit. Note the precise boundary: a JWT guarantees integrity, not confidentiality. It lets a server confirm "I issued this token and nobody edited it" without touching a database.

Its dominant use case is authentication: a user logs in, receives an access token, and attaches it to every subsequent request. The server verifies the signature and identifies the user with no session store at all.

How it differs from a classic session

Dimension Session + cookie JWT
Server state Session store required (memory / Redis) Stateless; the token is self-contained
Horizontal scaling Needs shared storage or sticky sessions Just add machines
Cross-domain / cross-service Cookie domain rules get painful Lives in the Authorization header, naturally cross-origin
Revocation Delete it server-side and it dies Not revocable by default; needs extra design
Cost per request One storage lookup One signature verification (usually faster)
Payload size Just a session id Carries every claim, so headers get bigger

The takeaway is not "one is better". If you need instant revocation and tight control, sessions are less work. If you run multiple services, multiple clients, and need to scale out, JWTs fit better.

Taking a JWT apart by hand

A JWT is three Base64Url strings joined by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Drop it into the JWT parser and the three JSON segments appear instantly. To understand the mechanics, decode the first two segments yourself with the Base64 converter.

Segment 1: Header

{ "alg": "HS256", "typ": "JWT" }
Field Meaning Common values
alg Signing algorithm HS256 / RS256 / ES256 / EdDSA / none
typ Token type JWT (at+jwt for nested cases)
kid Key ID Locates the public key in a JWKS; required for key rotation
jku JWKS URL Use with care — see the attacks section
cty Content type Used when nesting a JWS inside a JWE

Segment 2: Payload

The payload is a list of claims in three categories.

Registered claims (defined by RFC 7519; all optional, all worth using)

Claim Meaning Notes
iss Issuer Prefer a full URL such as https://auth.example.com
sub Subject Usually the user id — globally unique and never reused
aud Audience String or array; the verifier must find itself in it
exp Expiration time Unix timestamp in seconds
nbf Not before Tokens earlier than this must be rejected
iat Issued at Lets you compute token age
jti JWT ID The key to replay protection and deny-lists

Public claims: must avoid collisions — register with IANA or namespace them with a URI, e.g. https://oltool.net/claims/role.

Private claims: whatever both sides agree on, such as role or tenant_id.

⚠️ Timestamps are in seconds, not milliseconds. Passing Date.now() straight through yields an expiry 50 years out — one of the most common production blunders.

Segment 3: Signature

The signature covers the first two segments. For HS256:

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

RS256 uses RSASSA-PKCS1-v1_5(SHA-256, signingInput), ES256 uses ECDSA(P-256, SHA-256). Notice the counter-intuitive part: the signature does not encrypt anything. It digests the exact byte string of the first two segments, then appends the Base64Url result. Verification simply recomputes it from the same input and compares.

Base64Url is not secrecy

This is the trap everyone falls into. Base64Url is just an encoding that makes binary safe for URLs; any decoder reverses it. Verify it yourself: copy the payload segment and decode it with the Base64 converter.

Safe to include: user id, roles, tenant, expiry, scopes, session identifier.

Never include: passwords, keys, national ID numbers, card numbers, full phone numbers, internal topology.

When you genuinely need confidentiality: that is JWE (RFC 7516), an encrypted token with a five-segment structure — a different standard from JWS. But in most cases the right answer is not JWE; it is not putting sensitive data in the token at all. A token exists to prove identity, not to act as a database row.

Choosing a signing algorithm

Algorithm Type Key size Signature size Use when
HS256 Symmetric HMAC ≥ 256 bit random 43 B One service issues and verifies
HS384 / HS512 Symmetric HMAC ≥ 384 / 512 bit 64 / 86 B Compliance demands a stronger digest
RS256 RSA PKCS#1 ≥ 2048 bit (3072 preferred) 342 B Distributed systems where compatibility wins
PS256 RSA-PSS ≥ 2048 bit 342 B You want probabilistic signatures and padding-attack resistance
ES256 ECDSA P-256 256 bit 86 B Mobile or bandwidth-sensitive clients
EdDSA Ed25519 256 bit 86 B Greenfield systems; simple to implement, no nonce pitfalls

Decision order:

  1. Issuer and verifier are the same single service → HS256.
  2. Multiple verifiers or third parties → RS256 (compatibility) or ES256 (shorter).
  3. Brand-new system, both ends under your control → EdDSA, avoiding ECDSA nonce-reuse risk.
  4. Regulatory or national-algorithm requirements → follow the mandated suite (e.g. SM2).

You can mint a keypair with the RSA key generator, and see the difference between keyed and unkeyed digests by comparing the HMAC generator with the plain hash tool.

The seven-step verification checklist

Verification is not "the library returned true". This is the order production code must follow:

  1. Structure: exactly three segments, each valid Base64Url.
  2. Algorithm allow-list: read the permitted alg values from your own config — never let the token choose which key is used.
  3. Resolve the key: by kid when present; otherwise the single configured key.
  4. Verify the signature: before parsing any claim.
  5. Time: check exp (with leeway), nbf, and that iat is not in the future.
  6. Origin: check iss and aud against this service.
  7. Business rules: is jti deny-listed, is the user still active, do the scopes cover the request?

Step 2 is the one that gets skipped. Almost no JWT breach breaks the crypto; they all exploit a server that trusted the algorithm declared inside the token.

// ✅ Correct: the algorithm is pinned on the verifying side
jwt.verify(token, publicKey, { algorithms: ['RS256'], issuer, audience });

// ❌ Dangerous: the library picks the algorithm from the token
jwt.verify(token, key); // an attacker switches to HS256 and signs with your public key

Seven classic attacks and their defences

Attack How it works Defence
alg: none Strip the signature; a server that does not pin the algorithm accepts it Enforce an allow-list and reject none explicitly
Algorithm confusion An RS256 service is fed an HS256 token signed with the public key as the HMAC secret Pin both algorithm and key type at verification time
Weak-secret brute force HS256 with secret / 123456 can be enumerated offline ≥ 256 bits of randomness, rotate regularly
JWKS / jku spoofing The attacker points jku at their own key set Ignore jku / x5u; load keys only from local config or a pinned JWKS endpoint
kid injection kid is concatenated into a file path or SQL fragment Use kid only as a dictionary key — never in paths or queries
XSS theft The token sits in localStorage and is read by injected script See the storage FAQ, plus a strict CSP
Replay A captured token is reused Short exp, one-time jti checks, and HTTPS everywhere

One corollary: the blast radius of a leaked token equals its remaining lifetime. Shrinking an access token from 7 days to 15 minutes is the highest return-on-effort security change on this list.

Lifecycle and revocation

The two-token model

Token Lifetime Storage Purpose
Access token 5–15 minutes Memory / Authorization header Calling business APIs
Refresh token 7–30 days HttpOnly cookie scoped to /refresh Minting new access tokens

Refresh tokens must rotate: every exchange issues a new one and invalidates the old. If an already-retired refresh token shows up again, the token has leaked — revoke the entire chain and force re-authentication.

Three revocation strategies compared

Strategy Implementation Effect delay Cost
Short lifetime Config change ≤ access-token lifetime More refresh traffic
Deny-list (jti) Redis holding revoked, not-yet-expired ids Immediate Reintroduces state; only store unexpired ids so they age out
Version number A token_version column, embedded in the payload, compared on verify Immediate One lookup per verification (cacheable)

Compliance scenarios that need "log this person out now" justify a deny-list. Everything else is fine with short lifetimes plus a version column.

Status codes that go with it

Return these for token failures, and cross-check with the HTTP status code reference:

Situation Status Header
Token expired 401 WWW-Authenticate: Bearer error="invalid_token", error_description="The access token expired"
Invalid signature 401 Same, and log the jti for tracing
Insufficient scope 403 Do not downgrade to 404 — they mean different things
Refresh token rejected 401 Clear the cookie and force re-login

Recommended libraries

Language Libraries Notes
Node.js jose (preferred), jsonwebtoken With jsonwebtoken you must pass algorithms explicitly
Python PyJWT, python-jose PyJWT leaves iss / aud checks to you
Java nimbus-jose-jwt, jjwt Spring Security ships NimbusJwtDecoder
Go golang-jwt/jwt/v5, lestrrat-go/jwx v5 requires declaring algorithms up front
PHP firebase/php-jwt, lcobucci/jwt The latter has a more modern API
Rust jsonwebtoken Mind the clock leeway setting

When not to use a JWT

  • Admin back-offices needing second-level revocation — sessions are simpler and safer.
  • Treating the JWT as a full session bucket — the payload travels on every request; overstuffing slows APIs and can blow past gateway header limits (some cap at 8 KB).
  • Using it instead of encryption for sensitive data — that is JWE's or TLS's job.
  • A simple monolith login — the added complexity may buy you nothing.

Pre-launch checklist

  • [ ] alg comes from a server-side allow-list, never from the token
  • [ ] Key is ≥ 256 bits of randomness and never committed
  • [ ] exp / nbf / iat use second-precision timestamps
  • [ ] iss and aud are verified
  • [ ] Leeway set to 30–60 seconds
  • [ ] No sensitive data anywhere in the payload
  • [ ] Access token ≤ 15 minutes, refresh tokens rotate
  • [ ] jku / x5u ignored; key source is pinned
  • [ ] HTTPS everywhere; tokens never in URLs or logs
  • [ ] jti present, with a working revocation path

Open the JWT parser and paste any token in to see the three segments — the whole process runs in your browser, and the token never leaves your machine.

Advertisement

Frequently Asked Questions

The JWT payload looks like gibberish — why do you call it plaintext?

JWTs use Base64Url *encoding*, not encryption. Any tool reverses it in three seconds. Open the [JWT parser](/jwt-parser.html), or paste the middle segment into the [Base64 converter](/base64-converter.html), and the payload becomes readable immediately. Never put passwords, ID numbers, or secrets in a JWT — only non-sensitive identifiers and timestamps.

Should I use HS256 or RS256?

When a single service both issues and verifies the token, HS256 is simplest: one shared secret, fast, hard to get wrong. Whenever the issuer and the verifier are *different* services — microservices, third-party APIs, mobile backends — use RS256 or ES256: sign with a private key, verify with a public key. Verifiers never need the ability to mint tokens, and a leaked public key does not compromise security. ES256 gives shorter, faster signatures; RS256 has the broadest compatibility.

Once a JWT is issued, can it ever be revoked?

A server-side session can be deleted outright; a self-contained JWT cannot, because the server holds no state. Three workarounds: (1) shorten the lifetime (5–15 minutes for access tokens) until the revocation window is acceptable; (2) keep a deny-list of revoked `jti` values that have not yet expired — this reintroduces state; (3) store a `token_version` on the user row, embed it in the payload at issue time, and bump it on password change or logout-everywhere. Options 1 and 3 cover most products.

Should a JWT live in localStorage or in a cookie?

Neither is absolutely safe; they fail in different ways. localStorage can be read by any XSS payload, but you control exactly when the token is sent. An HttpOnly + Secure + SameSite cookie cannot be read by script, but it is attached automatically to same-site requests, so you need separate CSRF protection. The current mainstream recommendation: keep the access token in memory (a variable or closure), put the refresh token in an HttpOnly cookie scoped to a dedicated path, and add `SameSite=None` plus a CSRF token for cross-site flows.

Why does JWT verification keep saying the token is not yet valid inside a container?

`nbf` (Not Before) is set from the issuer clock, so any drift between client and server triggers a false rejection — common on Kubernetes nodes, in CI, and across time zones. Two fixes: issue tokens from a single NTP-synced time source, and configure a 30–60 second leeway in your verification library (`clockTolerance` in jsonwebtoken, `leeway` in PyJWT). Remember that leeway applies to `exp` too, so keep it under a minute or you are simply extending token lifetime.

← Back to Blog