JWT Decoding vs Cryptographic Signature Verification: What Developers Can and Cannot Learn from a Token
Last Tuesday, a staging auth bug had three devs stumped for half an afternoon. Nginx was spitting 401 Unauthorized. But the tester dumped their Bearer token into Chrome's DevTools console, ran JSON.parse(atob(token.spli
Last Tuesday, a staging auth bug had three devs stumped for half an afternoon.
Nginx was spitting 401 Unauthorized. But the tester dumped their Bearer token into Chrome's DevTools console, ran JSON.parse(atob(token.split('.')[1])), and saw:
{
"sub": "usr_9912",
"role": "admin",
"exp": 1791592000
}
The expiration was hours away. The role string was right.
So why did the API gateway reject it?
Because Base64URL decoding doesn't verify signatures. It just prints whatever text is encoded in the middle chunk. The actual signing key on the auth service had been rotated thirty minutes earlier, so every signature check was failing cold at the reverse proxy.
I see this confusion constantly in backend code reviews. Developers assume that because a token decodes cleanly, it must be valid.
It isn't.
Decoding a token and verifying a token are completely different operations. Conflating them is how privilege escalation bugs and auth bypasses make it to production.
1. Decoding Is Just String Unmasking (There Is Zero Encryption Here)
Standard JSON Web Tokens (RFC 7519) aren't encrypted. Unless you specifically implement JWE (RFC 7516), your token is just three Base64URL strings separated by dots:
header.payload.signature
That middle chunk is plain text wrapped in Base64URL. You don't need private keys, certificates, or an auth server to read it. A ten-line script in Node or Python will extract the data instantly:
const rawToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c3JfOTkxMiIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTc5MTU5MjAwMH0.sample_signature_hash";
// Split and decode middle chunk
const base64Payload = rawToken.split('.')[1];
const decodedString = Buffer.from(base64Payload, 'base64url').toString('utf8');
console.log(JSON.parse(decodedString));
// Prints: { sub: "usr_9912", role: "admin", exp: 1791592000 }
Notice what we needed to get that data: nothing. No secrets, no certificates, no cryptographic hashing.
Because of that, decoding only tells you what the payload claims to say. It tells you nothing about whether:
- The token was actually created by your authentication server.
- An attacker tampered with the claims in transit.
- The signature matches the payload contents.
If a backend service accepts role: admin just by reading the JSON payload without validating the cryptographic signature, an attacker can base64-encode whatever claims they want, slap on a random signature string, and your system will grant them full access.
2. What Verification Actually Does Under the Hood
Signature verification is pure mathematics, not string parsing.
When your auth service issues a token, it glues the header and payload together:
base64(header) + "." + base64(payload)
Then it hashes those bytes using a cryptographic algorithm (HMAC-SHA256 for symmetric, or RSA/ECDSA for asymmetric) alongside a private key or secret. The resulting output is appended as the third part: the signature.
When the token reaches your backend API:
- Your server grabs the exact same incoming header and payload.
- It recomputes the cryptographic hash using its trusted key.
- It compares the newly generated hash against the signature attached to the token.
If an attacker changes even a single character in the payloadβsay, editing "usr_9912" to "usr_9913"βthe recomputed hash won't match. The signature check fails immediately, and the request drops with a 401 Unauthorized before touching your database.
3. The Classic Frontend Mistake: Leaking Symmetric Keys
I still see teams try to "verify" tokens inside React, Vue, or mobile apps using symmetric HMAC keys (HS256).
Don't do this.
With HS256, the exact same secret key is used to both sign and verify tokens. If you put that secret into your frontend build configuration, it gets compiled straight into your client-side JavaScript bundle. Any user can open DevTools, copy your secret, and mint their own valid tokens with arbitrary permissions for any account in your database.
The Safe Split:
-
Frontend clients (React, Vue, mobile apps): Decode only. Use decoded claims strictly for cosmetic UI statesβlike showing the user's username or checking
expto schedule a silent refresh call. Never make security decisions based on client-decoded data. -
Backend services & API gateways: Verify the signature on every single incoming request using public keys (
RS256/ES256) or server-side secrets (HS256).
4. Two Implementation Flaws to Watch Out For
If you write custom authentication middleware, keep an eye on these two traps:
Trap A: The alg: "none" Bypass
Older versions of libraries like python-jwt and node-jsonwebtoken had a critical design flaw: if an incoming token specified "alg": "none" in the header, the parser skipped signature validation entirely and treated the token as valid. Attackers could forge admin tokens, strip off the signature, set alg: "none", and bypass auth completely.
Modern libraries block this by default, but you should always hard-pin the accepted algorithms in your verifier:
import jwt
# Always hard-pin algorithms on the verifier side
try:
payload = jwt.decode(
raw_token,
public_key,
algorithms=["RS256"], # Pin strictly! Never let token choose.
options={"require": ["exp", "iss", "aud"]}
)
except jwt.InvalidSignatureError:
return {"error": "Invalid signature"}, 401
except jwt.ExpiredSignatureError:
return {"error": "Token expired"}, 401
Trap B: Algorithm Confusion (RS256 vs HS256)
If your auth service signs tokens with asymmetric RSA (RS256), your API services verify using the RSA Public Key.
In an algorithm confusion attack, an attacker takes your public key (which is publicly accessible), changes the token header from RS256 to HS256, and signs the token using your public key string as an HMAC secret! If your backend verifier doesn't enforce the algorithm, it might treat the public key as raw HMAC bytes and validate the forged signature as legitimate.
Always pass algorithms=["RS256"] explicitly on your verifier. Never allow the token header to dictate which cryptographic algorithm to execute.
5. Debugging Tokens Locally Without Leaking Secrets
During local development and testing, you frequently need to inspect token payloads, check exp timestamps, and debug custom claims.
Pasting real staging or production tokens into random web decoders is risky. If the site routes requests through a backend server that logs incoming payloads, active session tokens can end up in third-party cloud logs.
If you need to inspect a token quickly in the browser without sending data across the network, you can use our in-browser tool:
π DevOmniTools JWT Decoder
It runs 100% locally in your browser memory using client-side JavaScript. Zero backend uploads, zero server requests, and zero remote logging.
Quick Architecture Checklist
- Frontends decode tokens solely for UI renderingβnever for authorization logic.
- Backends verify the cryptographic signature on every single protected API route.
- Never expose symmetric HMAC secrets inside client-side bundles or mobile binaries.
- Always hard-pin the allowed algorithms (
RS256orHS256) in your verification middleware. - Verify expiration (
exp), issuer (iss), and audience (aud) alongside the signature.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.