
JWT Decoding and Verification: What Is Actually Safe to Trust
Decoding a JWT and verifying one are not the same operation, and only one proves anything. A practical guide to the three Base64url parts, the alg attacks that breached real systems, correct verification with jose, claim validation, and safe client storage.

Mohammed Banani
0
Claps
JWT Decoding and Verification: What Is Actually Safe to Trust
A JSON Web Token looks like a password. It is long, it is opaque, it arrives in an Authorization header, and it gates access to things. So people treat it like a password: they assume that holding it, reading it, or checking it are all the same secured operation. They are not.
The single most expensive misunderstanding in JWT-based auth is this: decoding a token and verifying a token are completely different things, and only one of them proves anything. Decoding is free, requires no secret, and can be done by anyone who can copy-paste. Verification is the part that actually matters, and it is the part that gets skipped, misconfigured, or quietly bypassed.
This guide walks through what a JWT really is, what each part of it can and cannot be trusted to tell you, the handful of vulnerabilities that have compromised real production systems, and how to verify a token correctly in 2026.
If you just want to inspect a token while debugging, our JWT Decoder does it entirely in your browser. The token never leaves your machine, which matters more than it sounds like it should (more on that below).
A JWT Is Three Base64url Strings
A signed JWT is three pieces joined by dots:
header.payload.signatureEach of the first two pieces is just Base64url applied to a JSON object. The third is a signature computed over the first two. Here is a real (expired, harmless) token:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFkYSBMb3ZlbGFjZSIsImlhdCI6MTcxNzI4MDAwMCwiZXhwIjoxNzE3MjgzNjAwfQ.K8x2H5h3o0nq2H3a8mJfQ0qZ8c0Vd0lN6mQh2bWcq1ASplit it on the dots and Base64url-decode the first two parts and you get back plain JSON:
// header
{
"alg": "HS256",
"typ": "JWT"
}// payload
{
"sub": "1234567890",
"name": "Ada Lovelace",
"iat": 1717280000,
"exp": 1717283600
}Notice what just happened. We read the entire contents of the token without any key, secret, or permission. A JWT is not encrypted. Base64url is an encoding, not encryption. Anyone who intercepts the token, finds it in a log file, or pulls it out of browser storage can read every claim inside it.
That has one immediate consequence worth burning into memory: never put anything secret in a JWT payload. No passwords, no API keys, no internal database IDs you would not show the user, no PII you would not print on a billboard. The payload is readable by definition.
Decoding Proves Nothing
Here is the trap. Decoding the token above tells you it claims to be Ada Lovelace, user 1234567890. It does not tell you that claim is true.
I can take that token, change the payload to "sub": "1" and "name": "Administrator", Base64url-encode it again, and send it to your server. If your server decodes the token and reads sub without checking the signature, I am now the administrator. I did not need your secret. I needed a text editor.
The signature is the only part that makes a JWT trustworthy. It is computed like this:
signature = HMAC_SHA256(
base64url(header) + "." + base64url(payload),
secret
)For symmetric algorithms (the HS* family) the secret is a shared key that both the issuer and the verifier hold. For asymmetric algorithms (the RS*, ES*, PS* families) the issuer signs with a private key and verifiers check with the matching public key.
Either way, the property you get is: if even one byte of the header or payload changes, the signature no longer matches, and a verifier who knows the key can detect that. Tampering breaks the signature. That is the whole point of the design.
So the rule is blunt. Decode to inspect. Verify to trust. Reading the payload of an unverified token and acting on it is the equivalent of letting anyone in who is willing to write their own ID badge.
The alg Header Is the Dangerous Part
The header field that has caused the most real-world damage is alg. It tells the verifier which algorithm to use. The problem is that alg lives inside the token, which means it is attacker-controlled. Two classic attacks come straight out of trusting it.
alg: none
Early in the JWT spec there is an algorithm literally called none, meant for tokens that have already been secured by some other layer. A token using it has an empty signature:
{
"alg": "none",
"typ": "JWT"
}If a verifier reads alg from the token and dispatches on it, an attacker can set alg to none, drop the signature entirely, and forge any payload they like. Libraries have shipped this default. Systems have been breached by it.
The fix is to never let the token decide its own algorithm. The verifier picks the allowed algorithms out of band and rejects anything else.
RS256 to HS256 confusion
This one is subtler and it has bitten well-known products. Suppose your server uses RS256: tokens are signed with an RSA private key and verified with the RSA public key. The public key is, by design, public.
An attacker takes your public key (often genuinely published, or recoverable), then crafts a token with the header changed to HS256. Now they sign the token using HMAC with the public key string as the HMAC secret. If your verification code looks like verify(token, publicKey) and trusts the token's own alg, the library will see HS256, treat the provided key as an HMAC secret, and HMAC-verify the attacker's token with a "secret" the attacker already knows. Forgery succeeds.
The root cause is identical to alg: none: the verifier let the token choose the algorithm. The fix is identical too. Pin the algorithm.
// Vulnerable: the token gets to pick the algorithm.
jwt.verify(token, key);
// Safe: you pick. Anything else is rejected before the signature is checked.
jwt.verify(token, key, { algorithms: ['RS256'] });If you remember one line of code from this article, make it the second one.
Verifying a Token Properly in 2026
For new Node and edge code, jose is the library to reach for. It is standards-complete, has no legacy footguns enabled by default, and runs on the Web Crypto API, so it works in Node, Cloudflare Workers, Deno, Bun, and the browser. Here is a correct verification:
import { jwtVerify, createRemoteJWKSet } from 'jose';
// Public keys fetched and cached from the issuer's JWKS endpoint.
const JWKS = createRemoteJWKSet(
new URL('https://issuer.example.com/.well-known/jwks.json')
);
async function verify(token) {
const { payload } = await jwtVerify(token, JWKS, {
algorithms: ['RS256'], // pin the algorithm
issuer: 'https://issuer.example.com',
audience: 'https://api.example.com',
});
return payload;
}Three things in that options object do most of the security work:
algorithmsstops both attacks from the previous section. The token does not get a vote.issuerrejects tokens minted by anyone other than the issuer you trust. A valid signature from the wrong issuer is still the wrong token.audiencerejects tokens that were issued for a different service. A token your auth server made for your billing API should not unlock your admin API, even though both trust the same issuer.
jwtVerify also checks exp and nbf automatically and throws if the token is expired or not yet valid. If you verify by hand for some reason, you have to check those yourself, and you have to remember that they are NumericDate values: seconds since the Unix epoch, not milliseconds. Comparing a seconds timestamp against Date.now() (milliseconds) is a common and silent bug that makes every token look like it expired in 1970.
The Claims, and Which Ones to Validate
The payload is a bag of claims. A handful are standardized (the spec calls them registered claims) and they exist precisely so verifiers have predictable things to check:
| Claim | Meaning | Validate by |
|---|---|---|
iss |
Issuer | Matching against your expected issuer |
sub |
Subject (usually the user) | Using it only after verification |
aud |
Audience (intended recipient) | Matching against your service identity |
exp |
Expiration time | Rejecting if now is past it |
nbf |
Not before | Rejecting if now is before it |
iat |
Issued at | Optional freshness or age checks |
jti |
Unique token ID | Denylist lookups for revocation |
A signature that checks out only tells you the token is authentic and untampered. It does not tell you the token is for you or still valid. That is what aud, iss, exp, and nbf are for, and skipping them is how a perfectly valid token gets accepted in the wrong place at the wrong time.
One practical note on exp: allow a small clock-skew tolerance, usually 30 to 60 seconds. Server clocks drift, and a token issued by one machine and checked by another can otherwise be rejected a few seconds early or accepted a few seconds late. jose exposes this as a clockTolerance option.
Picking and Protecting the Secret
For HS256, the signature is only as strong as the shared secret. A short or guessable secret means an attacker can brute-force it offline against a token they captured, recover the key, and then mint valid tokens forever. HS256 uses SHA-256 under the hood, so the secret should carry at least 256 bits of entropy. Generate it from a CSPRNG, not from a passphrase someone typed.
# 32 random bytes, Base64-encoded, for an HS256 secret.
openssl rand -base64 32If you want to see how the underlying hashing behaves, the Hash Generator shows SHA-256 and the rest of the family operating on arbitrary input. It is a useful way to build intuition for why a weak secret is a weak signature.
For anything where the verifier is a different party than the issuer (third-party APIs, multi-service architectures, anything public-facing), prefer an asymmetric algorithm like RS256 or ES256. Then verifiers only ever hold the public key, and a leaked verifier cannot mint tokens.
Where Tokens Live, and the XSS Problem
Because the payload is readable and the token is a bearer credential (whoever holds it can use it), where you store it on the client matters.
Storing a JWT in localStorage makes it trivially readable by any JavaScript running on the page. That is convenient until a single cross-site scripting bug, in your code or in any dependency you ship, gives an attacker a one-line localStorage.getItem('token') exfiltration. A bearer token in localStorage is an XSS payday.
The more defensible pattern is an HttpOnly, Secure, SameSite cookie, which JavaScript cannot read at all, paired with CSRF protection. It is more work and it is the right amount of work for a credential.
Either way, keep token lifetimes short. JWTs are stateless by design, which is their headline feature and their sharpest edge: there is no built-in way to revoke one before it expires. If a token leaks and it is valid for 30 days, it is valid for the attacker for 30 days. The usual answer is short-lived access tokens (minutes) plus a longer-lived refresh token held somewhere safer, and a jti denylist for the rare case where you must kill a specific token immediately.
Decoding Safely While Debugging
You will spend real time staring at tokens during development, checking what an identity provider actually put in the sub, or why an aud mismatch is rejecting a request. The instinct is to paste the token into the first decoder a search turns up.
Think about what that token is. It is, in most cases, a live bearer credential for a real session. Pasting it into a random website sends your credential to that website's server. Many JWT decoders are server-side. Your token is now in someone else's logs.
This is the boring reason our JWT Decoder runs the decode entirely client-side. The token is split and Base64url-decoded by JavaScript in your own tab and never touches our servers. For a credential, "never leaves your machine" is not a marketing line, it is the security property you actually want from a debugging tool.
A Short Checklist
If you are shipping JWT auth, walk this list before you ship:
- The verifier pins allowed algorithms; the token's
algis never trusted to choose. -
alg: noneis impossible to reach in your verification path. -
issandaudare validated, not just the signature. -
expandnbfare checked, in seconds, with a small clock-skew tolerance. - HS256 secrets carry 256+ bits of CSPRNG entropy; cross-party setups use asymmetric keys.
- No secrets or sensitive PII sit in the payload.
- Tokens are short-lived, with a refresh strategy and a revocation path for emergencies.
- Client storage is
HttpOnlycookies where it can be, notlocalStorage.
None of these are exotic. Every JWT breach worth reading about traces back to one of them being skipped because a valid-looking token felt like proof. It is not proof until you verify it, on your terms, with the algorithm you chose.

