How JWT Works and How to Decode a Token Safely
Understand the structure of JSON Web Tokens, how signatures are created and verified, common claims, security pitfalls and how to decode a JWT.
· 5 min read
JSON Web Tokens (JWTs) are everywhere: OAuth and OpenID Connect logins, API gateways, single sign-on between microservices, and mobile app sessions. They are also widely misunderstood. Developers decode a token, see readable JSON and wonder whether that is a security problem; others store sensitive data in tokens, assuming they are encrypted. This article explains exactly what a JWT is, how it is validated, and how to inspect one without creating risk.
The three parts of a JWT
A JWT looks like three random strings joined by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsImV4cCI6MTkwMDAwMDAwMH0.Kx8...
Each part is Base64URL-encoded:
- Header — JSON describing the token type and signing algorithm, for example
{"alg":"HS256","typ":"JWT"}. - Payload — JSON containing claims: statements about the user and the token, such as
{"sub":"42","exp":1900000000}. - Signature — bytes computed from the header and payload with a secret or private key.
Base64URL is a variant of Base64 that uses - and _ instead of + and / and drops padding, so tokens can be placed in URLs and headers without escaping.
Crucially, the header and payload are only encoded, not encrypted. Anyone holding the token can read them. Paste a token into a JWT decoder and the payload appears instantly — that is by design.
How the signature works
The signature is what makes a JWT trustworthy. The issuer takes the encoded header and payload, joins them with a dot, and signs that string.
With HS256 (HMAC with SHA-256), the issuer and verifier share one secret key:
signature = HMAC-SHA256(secret, base64url(header) + "." + base64url(payload))
With RS256 or ES256, the issuer signs with a private key and anyone can verify with the matching public key, often published at a JWKS endpoint such as https://login.example.com/.well-known/jwks.json. Asymmetric algorithms are preferred when many services need to verify tokens but only one should issue them.
When a server receives a token, it recomputes or verifies the signature. If even one character of the payload changed — say an attacker edited "role":"user" to "role":"admin" — the signature no longer matches and the token is rejected.
Registered claims you should know
The JWT specification (RFC 7519) defines standard claim names:
iss(issuer): who created the token, for examplehttps://login.example.com.sub(subject): who the token is about, usually a user ID.aud(audience): who the token is intended for, such asapi.example.com.exp(expiration): the time after which the token must be rejected, in Unix seconds.nbf(not before): the time before which the token is not valid.iat(issued at): when the token was created.jti(JWT ID): a unique identifier, useful for revocation lists.
Applications add their own claims: scope, roles, tenant_id, email. Keep them few; every claim travels with every request.
Validating a token correctly
A server must check all of the following, in this order:
- The algorithm is one you expect. Configure an allow-list such as
["RS256"]. Never let the token's header decide freely; historic vulnerabilities let attackers switch tononeor trick servers into using a public key as an HMAC secret. - The signature is valid with the correct key, chosen by
kidfrom your trusted key set. expis in the future andnbfis in the past, allowing a small clock skew of 30–60 seconds.issmatches your identity provider exactly.audcontains your service's identifier, so a token issued for another API cannot be replayed against yours.
Use a maintained library — jose for JavaScript, PyJWT for Python, golang-jwt for Go — rather than writing verification yourself.
Decoding versus verifying
Decoding means Base64URL-decoding the first two parts and parsing the JSON. It requires no key and proves nothing about authenticity. It is useful for:
- Debugging why an API returns 401: is
audcorrect? Has the token expired? - Checking which scopes or roles a login flow actually granted.
- Confirming that a token refresh produced a new
exp.
Verifying means checking the signature and claims as described above. Only servers should make authorisation decisions, and only after verifying.
Front-end code may decode a token to show the user's name or schedule a refresh before exp, but it must never rely on decoded claims for security, because a user can modify anything running in their browser.
Where to store tokens in the browser
There is no perfect answer, only trade-offs:
- HttpOnly, Secure, SameSite cookies cannot be read by JavaScript, which protects against token theft through cross-site scripting. They require CSRF protection, though SameSite=Lax or Strict covers most cases.
- Memory (a JavaScript variable) is safe from persistence but lost on reload; combine it with a refresh token in an HttpOnly cookie.
- localStorage is convenient but readable by any script on the page, so a single XSS vulnerability leaks every token.
For most web apps, short-lived access tokens in memory plus a refresh token in an HttpOnly cookie is a good balance.
Expiry and revocation
JWTs are stateless: a server can validate them without a database lookup. The flip side is that a valid token cannot easily be revoked before it expires. Mitigations:
- Keep access tokens short-lived — 5 to 15 minutes is common.
- Use refresh tokens, stored securely and checked against a database, to issue new access tokens.
- For high-risk actions, check a revocation list of
jtivalues or a per-user "tokens valid after" timestamp.
Common mistakes
- Putting secrets in the payload. Passwords, API keys and personal data are readable by anyone with the token. If you need confidentiality, use JWE (encrypted tokens) or keep the data server-side.
- Very long-lived tokens. A stolen token that is valid for a year is a year-long breach.
- Skipping audience checks. Without
audvalidation, a token for a low-privilege service can be used against a high-privilege one. - Weak HMAC secrets. HS256 secrets must be long and random — at least 256 bits. Short secrets can be brute-forced offline from a single token.
- Logging tokens. Access logs and error trackers often capture
Authorizationheaders. Redact them.
Inspecting a token safely
When debugging, use a decoder that runs locally in your browser so the token is never sent to a third party. Prefer expired or test-environment tokens when sharing screenshots or pasting into tickets. Check:
algandkidin the header match your provider's configuration.issandaudmatch what your API expects.exp,iatandnbfmake sense relative to the current time.- Scopes and roles contain what the operation requires.
Summary
A JWT is a signed, not encrypted, bundle of JSON claims. Its security comes entirely from signature verification and claim validation on the server. Decoding is harmless and useful for debugging; trusting decoded data without verification is the real risk. Keep tokens short-lived, keep payloads small and free of secrets, validate algorithm, signature, expiry, issuer and audience every time, and inspect tokens with tools that keep them on your machine.