Paste a token into a JWT decoder and you get a satisfying amount of output: the header, the claims, the timestamps turned into readable dates, often a status badge. It looks like a verdict. It is a transcription.
The specification is not coy about the difference. RFC 7519 §11.1, "Trust Decisions", in one sentence: "The contents of a JWT cannot be relied upon in a trust decision unless its contents have been cryptographically secured and bound to the context necessary for the trust decision. In particular, the key(s) used to sign and/or encrypt the JWT will typically need to verifiably be under the control of the party identified as the issuer of the JWT." Cryptographically secured. A decoder splits a string on dots, base64url-decodes two of the pieces and parses the JSON inside. It holds no key. It checks nothing.
Everything below is captured output from disposable containers run on 25 July 2026 — every key generated inside the container, nothing borrowed from a real system, nothing tidied afterwards. One of those runs is our own decoder getting this wrong, until this morning. It is kept in deliberately.
alg: none tokens — not "must never", and the RFC names a case where none is fineEncoded is not encrypted
Start with the part people are most often surprised by. A signed JWT hides nothing. The claims travel in base64url, a transport encoding — a way of getting arbitrary bytes through systems that only tolerate URL-safe characters. No key, no secret. Anybody holding the token can read every claim in it — paste a segment into our Base64 encoder and decoder with URL-safe input ticked and the JSON comes straight back.
Here is that, with the bluntest possible tool. The container has no openssl binary and no Python; the only decoder involved is BusyBox base64.
docker run --rm -v "$PWD/scripts":/s:ro node:22-alpine sh /s/02-make-and-read.sh
…
### STEP 2 — mint an RS256 JWT signed with that key
header JSON: {"alg":"RS256","typ":"JWT"}
payload JSON: {"iss":"https://issuer.example","sub":"user-4417","aud":"recatools-guide-demo","name":"Tan Wei Ming","role":"admin","iat":1784000000,"nbf":1784000000,"exp":1784003600}
…
### STEP 3 — read the payload with NOTHING but /bin/base64. No key. No library.
same string, + and / restored and padded to a multiple of 4, piped to base64 -d:
…
--- base64 -d output ---
{"iss":"https://issuer.example","sub":"user-4417","aud":"recatools-guide-demo","name":"Tan Wei Ming","role":"admin","iat":1784000000,"nbf":1784000000,"exp":1784003600}
### STEP 4 — prove the private key was NOT used in step 3
…
the private key was never opened by base64:
base64 is /bin/base64 — a coreutils decoder, it has no notion of keys.Signing protects integrity, not confidentiality. For that, you need an encrypted JWT, or JWE, a different object defined in RFC 7516. RFC 7519 §3 makes the distinction plain: a JWS has signed or MACed claims, while a JWE has encrypted claims. The shapes even differ on the wire — a signed JWT has three dot-separated parts, but a JWE in compact serialization has five.
That has a practical consequence. A bearer JWT is usually the credential itself, not a description of one: whoever holds the string can act as the subject until it expires. Pasting one into a chat window or a bug report hands over that account for the rest of the token's life. RFC 7519 §12 ends its list of mitigations with the strongest: "Omitting privacy-sensitive information from a JWT is the simplest way of minimizing privacy issues."
Change one claim; the decoder does not blink
We demonstrate with three tokens: one genuine token signed by the issuer; a second with an edited claim (viewer becomes admin) but the original signature; and a third, edited the same way, but re-signed with an attacker's key.
docker run --rm -v "$PWD/scripts":/s:ro node:22-alpine sh /s/03-tamper.sh
=== GENUINE TOKEN (role: viewer) ===
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQ0MTciLCJyb2xlIjoidmlld2VyIiwiZXhwIjoxNzg0MDAzNjAwfQ.VDfooDBlFltFyZdHG9mvfDK-sMrjqOHcLaUdbiVQJxXKvsX3Cd_AbreEy0iirRMqsbirz6HVabGlZ2_870aplVZwWlqsd64hyn5TBSMZsVghPzjH3Cg3H_TyyekArcif8izmvtHyOxtuivVLyUY2RDO2-bRtA4w4HloWGEZD4tx_n1qVNE8mOKIeC5Eo_jmiQmyBgqOknax4r72BzCCaZvX58tDjGKTA7MVdHEJz_2xc9ZfULTjT-bUfTFNO8pPvcHjqzk1JICS68qchxO28d_qDAtuqPBLbGamuOyMW3NkS9uMYNEaSZuDoGimLWTgV0N7wdHmTH2dSfVBIziS3Ag
verify(genuine) = true
decode(genuine).payload = {"sub":"user-4417","role":"viewer","exp":1784003600}
=== TAMPERED TOKEN (role flipped to admin, signature copied over) ===
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQ0MTciLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3ODQwMDM2MDB9.VDfooDBlFltFyZdHG9mvfDK-sMrjqOHcLaUdbiVQJxXKvsX3Cd_AbreEy0iirRMqsbirz6HVabGlZ2_870aplVZwWlqsd64hyn5TBSMZsVghPzjH3Cg3H_TyyekArcif8izmvtHyOxtuivVLyUY2RDO2-bRtA4w4HloWGEZD4tx_n1qVNE8mOKIeC5Eo_jmiQmyBgqOknax4r72BzCCaZvX58tDjGKTA7MVdHEJz_2xc9ZfULTjT-bUfTFNO8pPvcHjqzk1JICS68qchxO28d_qDAtuqPBLbGamuOyMW3NkS9uMYNEaSZuDoGimLWTgV0N7wdHmTH2dSfVBIziS3Ag
DECODING the tampered token — succeeds, and happily reports admin:
header = {"alg":"RS256","typ":"JWT"}
payload = {"sub":"user-4417","role":"admin","exp":1784003600}
VERIFYING the tampered token:
verify(forged) = false
=== TAMPERED + RE-SIGNED WITH AN ATTACKER KEY ===
DECODING it — still succeeds, still says admin:
payload = {"sub":"user-4417","role":"admin","exp":1784003600}
VERIFYING against the real issuer public key:
verify(resigned) = falseRead the decode lines alone and all three tokens say the same thing. admin, in well-formed JSON, in a token with three segments and a full-length signature. Nothing in the string announces that two of them are forgeries.
| The token | What a decoder shows | What the verifier says |
|---|---|---|
| Genuine, signed by the issuer | payload readable, role: viewer | verify = true |
| Payload edited, signature kept | payload readable, role: admin | verify = false |
| Payload edited, re-signed with the attacker's own key | payload readable, role: admin | verify = false |
Look again at the third row. That forgery carries a perfectly well-formed 256-byte RSA signature. It is the right length, the right shape, and completely worthless, because it was made with the wrong key. "The signature looks fine" is not a check anyone can perform by eye.
What validating actually involves
The procedure in RFC 7519 §7.2 is strict. Its opening paragraph warns that if any of its steps fail, "then the JWT MUST be rejected -- that is, treated by the application as an invalid input."
Ten steps. A decoder climbs the first four, jumps to the last two, and stops.
Steps 1 through 4 are parsing: find a period, take the part before it, base64url-decode it, confirm the result is "a UTF-8-encoded representation of a completely valid JSON object". Steps 9 and 10 do the same for the payload. Useful work, all of it string handling.
Step 7 is the one that matters: "If the JWT is a JWS, follow the steps specified in [JWS] for validating a JWS." Those steps, in RFC 7515 §5.2, lead to the one sentence a decoder can never execute: step 8's instruction to "Validate the JWS Signature...". No key, no step 7.
§7.2 does not stop when the maths works, either. Its closing paragraph: "Even if a JWT can be successfully validated, unless the algorithms used in the JWT are acceptable to the application, it SHOULD reject the JWT." A correct signature over an algorithm you never agreed to accept is still a rejection. Ignoring that rule led to the most common JWT exploits.
alg: none, and the day RS256 became HS256
The alg header parameter tells the recipient which algorithm secured the token. It arrives inside the token, which means it arrives from whoever sent the token. RFC 7515 §10.7 names the resulting family of problems: "algorithm substitution attacks, in which an attacker can use an existing digital signature value with a different signature algorithm to make it appear that a signer has signed something that it has not."
Two substitutions have caused most of the damage. RFC 8725 — the JWT Best Current Practice, published February 2020 — lists them in §2.1. Note whose scare quotes those are:
"Signed JSON Web Tokens carry an explicit indication of the signing algorithm, in the form of the 'alg' Header Parameter, to facilitate cryptographic agility. This, in conjunction with design flaws in some libraries and applications, has led to several attacks: The algorithm can be changed to 'none' by an attacker, and some libraries would trust this value and 'validate' the JWT without checking any signature. An 'RS256' (RSA, 2048 bit) parameter value can be changed into 'HS256' (HMAC, SHA-256), and some libraries would try to validate the signature using HMAC-SHA256 and using the RSA public key as the HMAC shared secret."
The second one needs nothing secret. An RSA public key is published on purpose, usually at a JWKS endpoint; feed it to an HMAC function as if it were a shared secret and you can mint tokens that a library trusting the header's alg will accept. Tim McLean, whose March 2015 write-up is the disclosure RFC 8725 cites, put it in one line: "If a server is expecting a token signed with RSA, but actually receives a token signed with HMAC, it will think the public key is actually an HMAC secret key." Which means "anyone with knowledge of the public key can forge tokens that will pass verification." The node jsonwebtoken case became CVE-2015-9235, scored 9.8 CRITICAL by NVD.
Both forgeries were put in front of a decoder and then in front of two verifiers — one that pins the algorithm it expects, one that trusts whatever the header says.
docker run --rm -v "$PWD/scripts":/s:ro node:22-alpine sh /s/04-alg-none-and-confusion.sh
=== GENUINE RS256 ===
token : eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQ0MTciLCJyb2xlIjoidmlld2VyIiwiZXhwI…
parts : 3 (dot-separated segments)
header : {"alg":"RS256","typ":"JWT"}
payload : {"sub":"user-4417","role":"viewer","exp":1784003600}
sig : 342 base64url chars = 256 bytes
DECODE : SUCCEEDS — a decoder has nothing to object to
=== FORGED alg:none ===
token : eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyLTQ0MTciLCJyb2xlIjoiYWRtaW4iLCJleHAiOj…
parts : 3 (dot-separated segments)
header : {"alg":"none","typ":"JWT"}
payload : {"sub":"user-4417","role":"admin","exp":1784003600}
sig : 0 base64url chars = 0 bytes
DECODE : SUCCEEDS — a decoder has nothing to object to
=== FORGED HS256 key-confusion ===
token : eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQ0MTciLCJyb2xlIjoiYWRtaW4iLCJleHAiO…
parts : 3 (dot-separated segments)
header : {"alg":"HS256","typ":"JWT"}
payload : {"sub":"user-4417","role":"admin","exp":1784003600}
sig : 43 base64url chars = 32 bytes
DECODE : SUCCEEDS — a decoder has nothing to object to
=== VERIFIER: RS256 only, against the issuer public key ===
genuine RS256 -> ACCEPT
forged alg:none -> reject: alg is "none", expected RS256 (RFC 8725 §3.1)
forged HS256 confusion -> reject: alg is "HS256", expected RS256 (RFC 8725 §3.1)
=== VERIFIER: the BROKEN kind — trusts the header alg (RFC 8725 §2.1) ===
genuine RS256 -> ACCEPT
forged alg:none -> ACCEPT (no signature checked) <-- privilege escalation
forged HS256 confusion -> ACCEPT (HMAC with the public key as secret) <-- privilege escalationThe decoder column is identical down all three rows. Three segments, valid JSON, sensible-looking claims. The only visible difference is signature length — 342 characters, then nothing at all, then 43 — and length is a fingerprint, not a verdict. RFC 7518 §3.3 requires "A key of size 2048 bits or larger" for the RS family, which is why the smallest legal RS256 signature is 256 bytes; the container used exactly 2048 bits. This difference in length is useful for spotting a mismatch but useless as a security control, as the re-signed forgery showed.
Unsecured tokens are not an accident of the format; they are in it. RFC 7519 §6 provides for JWTs "created without a signature or encryption", and RFC 7518 §3.6 spells out the obligations that come with supporting them: "An Unsecured JWS uses the 'alg' value 'none' and is formatted identically to other JWSs, but MUST use the empty octet sequence as its JWS Signature value… Implementations that support Unsecured JWSs MUST NOT accept such objects as valid unless the application specifies that it is acceptable for a specific object to not be integrity protected. Implementations MUST NOT accept Unsecured JWSs by default."
"Formatted identically to other JWSs" is the spec explaining, in one phrase, why a decoder cannot rescue you.
Contrary to a line that circulates widely, proper libraries do not "always reject" none. RFC 8725 §3.2 says "JWT libraries SHOULD NOT consume JWTs using 'none' unless explicitly requested by the caller" — SHOULD NOT, not MUST NOT — and it describes where the algorithm is appropriate: "if a JWT is cryptographically protected end-to-end by a transport layer, such as TLS using cryptographically current algorithms, there may be no need to apply another layer of cryptographic protections to the JWT. In such cases, the use of the 'none' algorithm can be perfectly acceptable." The protection has to exist somewhere. It does not have to be inside the token.
The fix is not a cleverer decoder, but a stricter verifier that pins the algorithm, per RFC 8725 §3.1: "Libraries MUST enable the caller to specify a supported set of algorithms and MUST NOT use any other algorithms when performing cryptographic operations." In the strict run above, both forgeries are rejected on the algorithm mismatch before any signature maths happens.
The claims oblige the reader, not the token
Suppose the signature does verify. You are still not finished, because the claims are instructions to the recipient.
exp is "the expiration time on or after which the JWT MUST NOT be accepted for processing", with "some small leeway, usually no more than a few minutes" allowed for clock skew. nbf is "the time before which the JWT MUST NOT be accepted for processing". Both are NumericDate values — seconds since 1970-01-01T00:00:00Z, "ignoring leap seconds" — which is why a reader that treats them as milliseconds lands in 1970 rather than next Tuesday.
Then the two that decoders never check and applications often forget. RFC 7519 §4.1.3 on aud: "If the principal processing the claim does not identify itself with a value in the 'aud' claim when this claim is present, then the JWT MUST be rejected." RFC 8725 §3.8 on iss: "When a JWT contains an 'iss' (issuer) claim, the application MUST validate that the cryptographic keys used for the cryptographic operations in the JWT belong to the issuer. If they do not, the application MUST reject the JWT." A token can be genuinely signed by a real issuer, for a completely different service, and still be worthless to you.
The refrain through RFC 7519 §4.1 is "Use of this claim is OPTIONAL". Absence proves nothing. A token with no exp has not passed a check; it simply never expires.
Our own decoder said "Valid"
Our JWT decoder made exactly the mistake this guide is about. Until this morning its status row printed a green ✅ Valid, and the only thing that badge tested was whether exp was in the future. We found it while researching this guide, by copying the tool's own decode function out of app.js and running it against adversarial input under a fixed clock.
docker run --rm -v "$PWD/scripts":/s:ro node:22-alpine node /s/05-recatools-decoder-replay.js
fixed clock for this run: 2026-07-25T10:30:00Z (1784975400000 ms)
node atob available: function
…
CASE: alg:none forgery, role=admin, exp in the future
note: RFC 7519 §6 Unsecured JWT: empty signature, trailing dot
token: eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyLTQ0MTciLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE5MDAwMDAw…
decodeJWT() -> OK
header : {"alg":"none","typ":"JWT"}
payload : {"sub":"user-4417","role":"admin","exp":1900000000}
sig seg : "" (0 chars)
PAGE WOULD SHOW -> Issued At (iat): (row hidden)
Expires At (exp): 2030-03-17T17:46:40.000Z
Status: ✅ Valid
Time: <time remaining>
nbf: NO nbf ROW EXISTS IN THE MARKUPAn unsigned token claiming role: "admin", stamped Valid by us. A token with nbf years in the future got the same badge, because the tool parsed nbf and then threw it away without rendering it.
We fixed it. The status row is now labelled Status (time only) and reports one of four states — none a judgement about the token's authenticity:
⏰ Expired exp is in the past
⏳ Not usable yet (nbf) now is earlier than nbf
🕒 Not expired exp is in the future
⚠️ No expiry claim the token carries no exp at all
A permanent line sits under the row: "This status reflects the exp and nbf time claims only. No signature was checked. A token can be unexpired and still be forged, unsigned, or issued by someone you do not trust." There is now an nbf row, and a token declaring alg: none raises a banner under the header panel instead of passing as an ordinary header value. The same replay caught a second bug — non-ASCII claims were read as latin1, so 陈大文 came out as mojibake with no error — fixed by decoding the bytes as UTF-8, as §7.2 steps 4 and 10 require.
One thing the page always got right: the amber banner above the input, which cannot be dismissed, telling you never to paste a production token containing real user data into any online tool, "including this one". Take that seriously, for the reason in the first section — the token is the credential. The tool decodes entirely in your browser and sends nothing anywhere; use a test token anyway, because the next tool you try might not.
The server side of this site was already doing what the tool page was not. Our API signs with RS256 and decodes by handing the library a key object bound to that one algorithm, so an alg: none token and an RS256-to-HS256 swap are both rejected on the algorithm check before any signature maths runs.
FAQ
Can a decoder ever verify a signature?
Only if you give it the key, which changes what the page is. RFC 7515 §5.2 step 8 needs the public key for RS/ES tokens or the shared secret for HS tokens, and pasting a shared secret into a web page is worse than pasting the token. Verify in your application.
Is my JWT payload private if the connection uses TLS?
It is protected in transit and readable everywhere else — in logs, in browser storage, in the screenshot attached to a ticket. RFC 7519 §12 lists TLS as one mitigation and a JWE as another, then adds that "omitting privacy-sensitive information from a JWT is the simplest way of minimizing privacy issues".
My token has three parts and a long signature. Doesn't that mean it's signed properly?
No. In our run, a payload edited to say admin and re-signed with an attacker-generated key carried a full 256-byte RSA signature and failed verification against the issuer's public key. Shape and length are not evidence.
Should I reject every token with alg: none?
Reject it by default — RFC 7518 §3.6 says implementations "MUST NOT accept Unsecured JWSs by default". RFC 8725 §3.2 is deliberately a SHOULD NOT rather than an absolute, because a JWT already protected end-to-end by another mechanism can legitimately use none. If that is your situation, you will know; if you are unsure, it is not.
- RFC 7519 — JSON Web Token (JWT), May 2015 (§3, §4.1, §6, §7.2, §11.1, §12) (accessed 25 Jul 2026)
- RFC 7515 — JSON Web Signature (JWS) (§5.2, §10.7) (accessed 25 Jul 2026)
- RFC 7516 — JSON Web Encryption (JWE) (§1) (accessed 25 Jul 2026)
- RFC 7518 — JSON Web Algorithms (JWA) (§3.3, §3.6) (accessed 25 Jul 2026)
- RFC 8725 / BCP 225 — JSON Web Token Best Current Practices, February 2020 (§2.1, §3.1, §3.2, §3.8) (accessed 25 Jul 2026)
- CVE-2015-9235 Detail — NIST National Vulnerability Database (accessed 25 Jul 2026)
- Critical vulnerabilities in JSON Web Token libraries — Tim McLean, March 2015 (accessed 25 Jul 2026)
- Container transcripts, 25 July 2026 —
node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2, disposable--rmruns, all keys generated inside the container