If you've ever pasted a JWT into a debugger just to see three chunks of jumbled text and a green tick you didn't fully trust, this is for you. Decoding a JWT is the easy 90% of the job — reading the header and payload back as JSON takes one line of code. The part that actually matters for security — verifying the signature — is where most explanations either wave their hands or skip straight to "just use a library." This walks through what a JWT's three segments actually contain, how exp and the other standard claims work, and what it genuinely means to verify a signature for the two families you'll meet most often: HMAC (HS256) and public-key (RS256/ES256).
What a JWT actually is: three segments, not one blob
A JSON Web Token is three base64url-encoded pieces joined by dots:
header.payload.signature
// header
{ "alg": "HS256", "typ": "JWT" }
// payload
{ "sub": "user_42", "iat": 1751500000, "exp": 1751503600 }
Base64url is not encryption — it's a reversible text encoding, the same way a QR code isn't a secret. Anyone holding a JWT can decode the header and payload themselves with nothing more than atob() and a JSON parser. This surprises people the first time they realise it: a JWT's contents are never confidential, only (potentially) tamper-evident. If you need to hide the payload's contents from the bearer of the token, a JWT is the wrong tool — that's what encrypted JWTs (JWE) or an opaque, server-side session token are for.
Never put anything you don't want the token holder to read in a JWT's payload — session IDs, role names, and user IDs are fine; raw passwords, unmasked card numbers, or anything you'd wince at appearing in a browser's devtools are not.
Reading the standard claims: what exp, iat, nbf, aud, iss actually mean
The payload can hold whatever your application needs, but RFC 7519 defines a handful of registered claims most real-world tokens use:
| Claim | Meaning |
|---|---|
exp |
Expiry — Unix timestamp after which the token must be rejected |
iat |
Issued-at — Unix timestamp when the token was created |
nbf |
Not-before — Unix timestamp before which the token must NOT be accepted yet |
aud |
Audience — who the token is intended for |
iss |
Issuer — who created the token |
sub |
Subject — usually the user or account the token represents |
jti |
Unique token ID, often used to detect replay |
The three time-based claims (exp, iat, nbf) are all plain Unix seconds — no timezone, no ISO string, just an integer. That's exactly why "is my token expired?" is a more annoying question to answer by eye than it should be: you have to convert the number to a date yourself, and get the units right (seconds, not milliseconds — a common off-by-1000 bug). A 1751503600 sitting in a payload tells you nothing at a glance; converted, it's a specific date and time you can actually reason about.
Decode first, verify second — and why the order matters
Given a token, there are two genuinely different questions you can ask:
- "What does this token claim?" — answered by decoding. Split on the two dots, base64url-decode the header and payload, done. No key required, no trust implied.
- "Should I believe what it claims?" — answered by verifying. This requires the correct key and tells you whether the header and payload have been altered since they were signed.
A worryingly common mistake is reading claims out of a decoded-but-unverified token and acting on them — trusting a role: admin claim, say, without ever checking the signature. If the payload is just base64-decoded JSON, it's exactly as trustworthy as a text file the user handed you: anyone can write {"role":"admin"} into it. Decoding without verifying tells you what a token says; only verification tells you whether it's true.
Verifying HS256: the shared-secret family
HS256 (and its longer siblings HS384, HS512) use HMAC — a single shared secret that both the issuer and the verifier must hold. Verification recomputes the HMAC over the token's first two segments (base64url(header) + "." + base64url(payload)) using that secret, and compares the result byte-for-byte against the signature segment.
expected_signature = HMAC-SHA256(secret, header + "." + payload)
If expected_signature matches the token's actual signature segment, the token is genuine and untampered — provided the secret itself was never leaked. That's HS256's whole trust model in one sentence: anyone who holds the secret can both sign new tokens and verify existing ones, which is fine for a single backend service checking its own tokens, and a liability the moment that secret needs to be shared across multiple services or handed to a third party.
Verifying RS256 and ES256: the public/private-key family
RS256 (RSA) and ES256 (ECDSA) split signing and verifying into two different keys. A private key signs; a completely separate, non-secret public key verifies. This solves HS256's sharing problem cleanly: an identity provider can publish its public key openly, and any number of services can verify tokens against it without ever holding anything sensitive.
signature = Sign(private_key, header + "." + payload) // issuer only
is_valid = Verify(public_key, header + "." + payload, signature) // any verifier
RS256 uses RSA keys (typically PEM or JWK format); ES256 uses elliptic-curve keys, which are considerably smaller for equivalent strength — a practical reason ES256 has become popular where token size matters (URLs, headers, mobile). Either way, the shape of the trust model is identical: verifying a token never requires the private key, only the issuer's public key, which by design is safe to publish.
Two JWT security traps worth knowing by name
alg: none. The JWT spec allows a header to declare alg: none, meaning the token is deliberately unsigned — legitimate for some internal testing scenarios, but catastrophic if a verifier ever accepts one from the outside world without an explicit, deliberate opt-in. Some older or misconfigured libraries did exactly that: they read alg from the token itself and, on seeing none, skipped verification entirely — letting an attacker forge any payload they liked with zero key material. Any tool or library you use to inspect JWTs should flag alg: none loudly rather than quietly decode it as if nothing were wrong.
If you ever see alg: none on a token you didn't generate yourself for testing, treat it as a red flag, not a curiosity — it means the token carries no proof of integrity at all.
RS256-to-HS256 key confusion. This one is subtler. If a verifier trusts the alg value written in the token itself, an attacker can take a service's own — publicly available — RSA public key and resign a token with HS256, using that public key's bytes as the HMAC secret. A verifier that blindly does "whatever alg says, use this key" can end up HMAC-verifying against a key it never intended to be a shared secret, and pass. The fix lives entirely on the verifying side: pin the algorithm you expect (e.g. "this service only ever accepts RS256") rather than trusting the token to tell you what algorithm to use.
Try it: decode, verify, and build test tokens in one page
Skojio's JWT Decoder, Verifier & Encoder covers the whole loop described above in one page, entirely in your browser:
- Paste a token to see its header and payload decoded and every standard claim humanised — including
exp/iat/nbfconverted to local date/time, not just a raw integer - An at-a-glance validity banner tells you expired / not-yet-valid / valid, with a relative time ("expired 3 days ago")
- Verify HS256/384/512 against a shared secret, or RS256/384/512 and ES256/384/512 against a public key (PEM or JWK) — all via the browser's native WebCrypto, nothing sent anywhere
-
alg: nonetokens get a non-dismissible warning instead of a silent pass, and pasting a public key against an HMAC-alg token triggers an explicit key-confusion warning - An Encode & Sign mode lets you build and sign a test token from scratch — handy for reproducing an expiry bug or a malformed-token scenario without reaching for a CLI
It doesn't claim to be more private than every other JWT tool out there — several others are also client-side. What it does offer is a complete, vendor-neutral page with no account, no marketing chrome, and both directions (decode/verify and encode/sign) covered for all nine mainstream algorithms in one place. If you need a shared secret for HS256 testing, Skojio's Password Generator will generate one; if you're inspecting the base64url encoding itself rather than a full token, Base64 Encoder/Decoder is the standalone version of that same step.
- A JWT's header and payload are base64url-encoded, not encrypted — anyone holding the token can read them, so never put secrets in the payload
- Decoding tells you what a token claims; only verifying its signature tells you whether to believe those claims
- HS256 uses one shared secret for both signing and verifying; RS256/ES256 split signing (private key) from verifying (public key), which scales better across multiple services
-
exp,iat, andnbfare plain Unix-second timestamps — convert them to check expiry rather than eyeballing the raw integer - Watch for two specific traps:
alg: nonetokens that carry no signature at all, and RS256-to-HS256 key confusion, where a verifier that trusts the token's ownalgfield can be fooled into HMAC-checking against a public key
Top comments (0)