Skip to content

JWT Decode vs Verify: Why They're Not the Same Thing

JWT decode vs verify — decoding reads the payload, verifying proves the signature is real. Free JWT decoder with HS256/384/512 verification, entirely client-side.

Try it now: JWT Decoder Decode a JWT's header and payload, check expiry, and verify HS256/384/512 signatures — entirely client-side, and decoding is never confused with verifying.

What Is a JWT?

A JSON Web Token (JWT, pronounced "jot") is three base64url-encoded segments joined by dots: header.payload.signature. That's the entire structural definition — a JWT is a compact, URL-safe way to carry a signed JSON object, standardized by RFC 7519, with the signing mechanics themselves defined separately in RFC 7515 (JSON Web Signature). It shows up constantly as an authentication token: a server issues one after login, the client attaches it, and the single question this article keeps coming back to — JWT decode vs verify— decides whether reading that token proves anything at all to subsequent requests, and the server reads it back to know who's asking without a database lookup on every call.

Here's a real, well-formed token — the same illustrative example that shows up across most JWT tooling and documentation, because it's small enough to read in full:

a complete jwt — three segments joined by dots
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Split apart at the dots, the same string is three independent base64url blocks:

the same token, broken into its three segments
header:    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
payload:   eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
signature: SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Base64url is almost the same alphabet as ordinary base64, with two characters swapped (- and _ instead of + and /) and trailing = padding dropped, specifically so the result is safe to drop straight into a URL or an Authorizationheader without escaping. The first two segments are just base64url-encoded JSON — decode them and you get the header and payload back as plain, readable objects. The third segment is not encoded data at all; it's a cryptographic signature computed over the first two segments, and it's the only part of the token that requires a secret or private key to produce.

The Header and Payload: What's Actually in a JWT

Decoding the two JSON segments from the example above gives you this. The header is short and mostly fixed:

decoded header
{
  "alg": "HS256",
  "typ": "JWT"
}

typ just labels the object as a JWT. alg names the signing algorithm — HS256 here, an HMAC using SHA-256. It also names things like RS256 and ES256, and that one field matters a lot more than its size suggests — see the algorithm-confusion section below for why a decoder trusting it blindly is a real problem, not a theoretical one.

The payload carries the actual claims — the data the token is asserting:

decoded payload — standard and custom claims
{
  "iss": "https://auth.example.com",
  "sub": "1234567890",
  "aud": "https://api.example.com",
  "exp": 1735689600,
  "iat": 1735686000,
  "nbf": 1735686000,
  "role": "admin",
  "email": "[email protected]"
}

RFC 7519 defines seven standard ("registered") claim names, all optional:

  • iss (issuer) — who created and signed the token.
  • sub (subject) — who the token is about, typically a user ID.
  • aud (audience) — who the token is intended for; an API should reject a token whose auddoesn't name it, even if the signature is otherwise valid.
  • exp (expiration time) — a Unix timestamp after which the token must be rejected.
  • iat (issued at) — the Unix timestamp the token was created.
  • nbf (not before) — a Unix timestamp before which the token must not be accepted yet.
  • jti (JWT ID) — a unique identifier for the token itself, useful for one-time-use tokens or a revocation list.

Everything else — role, email, or any application-specific field — is a custom claim. Nothing in the spec restricts what goes in a JWT payload beyond "valid JSON", which is exactly why the signature matters so much: the payload is just data, and anyone can write data.

JWT Decode vs Verify: Two Different Operations

This is the single most important thing to understand about JWTs, and it's the thing that gets conflated constantly, in code and in tooling both: decoding a JWT and verifying a JWT are not the same operation.

Decodingis base64url-decoding the header and payload segments back into JSON. That's it. It requires no key, no secret, no network call — it's pure string manipulation, and it proves absolutely nothing about whether the token is genuine. Anyone can decode any JWT, including one they forged themselves five seconds ago with a text editor and a base64 encoder. If you can read this article, you can decode a JWT by hand.

Verifyingis the operation that actually matters for security: recomputing the signature over the header and payload using a known key or secret, and checking that it matches the signature segment already in the token. Verifying is the only step that proves the payload hasn't been altered since the token was issued, and the only step that proves it was actually issued by whoever holds the signing key — that is, it's the only step that establishes trust. Decoding establishes nothing.

The reason this distinction matters in practice, not just in theory: a server that decodes a token and reads its claims without verifying the signatureis trivially bypassable. An attacker doesn't need to steal a valid token or crack a secret — they can hand-craft a header and payload with whatever sub, role, or audthey want, base64url-encode it themselves, and send it straight to any endpoint that only decodes. There's no signature check standing in the way, because the code never asked for one.

That bug is more common than it should be precisely because decoding looks like it worked. The call returns a clean JSON object with a role: "admin"field, the response looks correct, and nothing about the code visibly breaks — until it's an attacker's forged payload sitting in that object instead of a real one. A JWT debugger that shows you decoded claims without making it unmistakable that nothing has been verifiedis walking a user straight toward that mistake. GenKitLab's JWT decoder treats decode and verify as two distinct actions in the interface, on purpose — you always know which one you just did.

"alg: none" and Algorithm Confusion: Why a Decoder Should Never "Look Verified"

Two attack classes make the decode-vs-verify distinction concrete rather than academic. Both exploit the same root mistake: trusting information inside an attacker-controlled token to decide how that token gets checked.

The alg: none attack

The JWS spec technically permits "alg": "none"— an explicitly "unsecured" JWT with an empty signature segment, meant for narrow cases where the token is already protected some other way. A verification path that reads the algfield out of the token itself and branches on it — "if alg says none, skip the signature check" — hands an attacker a direct bypass: take any token, decode the payload, change whatever claims you like, set alg to none, drop the signature entirely, and a naive verifier accepts it as-is.

RS256 / HS256 algorithm confusion

The second class targets systems that mix a symmetric algorithm (HS256, one shared secret for both signing and checking) with an asymmetric one (RS256, a private key signs and a public key verifies). RSA public keys are, by design, not secret — they're often published openly at a JWKS endpoint for anyone to fetch. If a verification library trusts the token's own alg header to decide which key to use and how, an attacker who knows the public key can sign a forged token with HS256, using that public key's bytes as the HMAC secret. A verifier that reasons "this token claims HS256, so check it against the key configured for this issuer" — without pinning that this issuer must always use RS256 — will compute a matching HMAC and accept a token it never actually issued.

The fix in both cases is the same: an application must pin the expected algorithm (and key) itself, server side, and never let the token dictate how it gets checked. Every well-maintained JWT library requires the caller to pass an explicit allowed-algorithms list rather than inferring one from the token — which is exactly why a decoder or debugger tool matters here: showing a header with "alg": "HS256" next to a payload is reading, not validating, and nothing about that display should ever be allowed to look like a green checkmark of authenticity.

How to Verify a JWT's Signature in Code

A browser tool is the right call for inspecting a token while debugging. Actual verification, in production, belongs in code, using a maintained library rather than hand-rolled HMAC comparison — a timing-safe comparison and correct algorithm pinning are both easy to get subtly wrong from scratch.

Python: PyJWT

python
import jwt

token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"

# Decode ONLY -- reads the claims, checks nothing.
# Anyone can produce a payload that decodes to whatever they want.
unverified = jwt.decode(token, options={"verify_signature": False})
print(unverified)  # {'sub': '1234567890', 'name': 'John Doe', 'iat': 1516239022}

# Verify -- recomputes the signature against a known secret and rejects
# a mismatch, an expired exp, or an algorithm you didn't explicitly allow.
claims = jwt.decode(token, key="your-256-bit-secret", algorithms=["HS256"])
print(claims)

PyJWT requires algorithms= explicitly on every call to jwt.decode() that verifies — that requirement exists specifically to prevent the algorithm-confusion class of attack above. Leaving it out, or building the algorithm list from the token itself, defeats the protection entirely.

JavaScript: jsonwebtoken (Node.js)

javascript
const jwt = require("jsonwebtoken");

const token =
  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";

// jwt.decode() never checks a signature at all -- it's decode-only, by design.
const unverified = jwt.decode(token);
console.log(unverified); // { sub: '1234567890', name: 'John Doe', iat: 1516239022 }

// jwt.verify() checks the signature, exp, and nbf, and throws on any failure.
try {
  const claims = jwt.verify(token, "your-256-bit-secret", { algorithms: ["HS256"] });
  console.log(claims);
} catch (err) {
  console.error("Token rejected:", err.message);
}

Notice the shape both libraries share: the decode-only call takes just the token, and the verify call always demands a key and an explicit algorithm list on top of it. That asymmetry in the function signatures is the API surface mirroring the exact conceptual split this whole article is about — code can't verify anything without being told what to trust.

Common JWT Errors and What They Look Like

Three failure modes account for almost every real-world JWT problem. Recognizing the shape of each one saves time debugging.

Malformed token (wrong segment count)

A JWT must have exactly three dot-separated segments. Anything else — a truncated copy-paste, a token with the signature accidentally stripped, or a stray newline splitting the string — fails before decoding even starts:

malformed — only two segments
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0

# jwt.decode(): jwt.exceptions.DecodeError: Not enough segments

Invalid signature

The token has the right shape — three valid base64url segments — and decodes cleanly, but the recomputed signature doesn't match. This happens when the secret or key is wrong, when the payload was edited after signing (even one character), or when someone hand-crafted a forged token without the real secret:

same header and payload, tampered — signature no longer matches
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.tampered_Xy9zK4mQssw5c

# jwt.decode(..., key=secret, algorithms=["HS256"]):
# jwt.exceptions.InvalidSignatureError: Signature verification failed

Expired token (exp in the past)

The token decodes fine and the signature is genuinely valid, but its expclaim is a Unix timestamp already in the past. A correct verifying library checks this by default and rejects the token even though the signature itself checks out — decoding alone would never catch this at all, since decoding doesn't look at the current time in the first place:

valid signature, expired -- exp was November 2023
{
  "sub": "1234567890",
  "iat": 1700000000,
  "exp": 1700003600
}

# jwt.decode(..., key=secret, algorithms=["HS256"]):
# jwt.exceptions.ExpiredSignatureError: Signature has expired

All three of these are checks a decoder alone cannot perform. A tool that only decodes has no secret to test a signature against and, depending on how it's built, may not even check the clock against expunless verification is explicitly requested — one more reason the decode/verify line has to stay visible rather than blurred into a single "paste token, see green checkmark" flow.

GenKitLab vs. jwt.io and Other JWT Decoders

jwt.io is the most common alternative most developers reach for first, and it's a solid, long-standing tool. The comparison below is about how each approaches the decode/verify split specifically, not a claim about every feature either tool has ever shipped.

GenKitLabjwt.ioGeneric decode-only tools
Runs entirely client-sideYes — nothing pasted is ever uploaded or loggedClient-side for decode; check current terms before pasting production secretsVaries widely by site
Decode and verify are distinct, separately-triggered actionsYes — decoding a token never implies or displays a verified stateA secret/key field sits alongside the decoded outputUsually decode-only, with no verification path at all
HS256 / HS384 / HS512 signature verificationYes, built inYes, via a pasted secretRarely offered
Asymmetric algorithms (RS256/ES256) verificationNot yet — HS256/384/512 only, by design, to keep the tool auditableSupported, via a pasted public keyRarely offered
Explains exp / nbf / malformed / invalid-signature failure modesThis pageLimited inline explanationRarely
Sign-up requiredNoNoVaries
Best fitReading and verifying a token while writing or debugging auth code, with the decode/verify line kept explicitA well-known, widely trusted general-purpose debugger, including asymmetric algorithmsA quick one-off peek at claims, with no verification

The honest gap in the other direction: jwt.io supports asymmetric verification (RS256/ES256) today, and GenKitLab's JWT decoder currently doesn't — it covers HS256, HS384, and HS512 signature verification, entirely client-side, on top of decoding. What it's built around instead is making sure the two operations never blur together on screen: decoding shows claims, verifying shows a pass/fail against a secret you provide, and the interface never lets one stand in for the other.

Explore More Developer Tools

Reading a JWT is one half of working with them. A few tools that pair naturally with a JWT debugger:

  • A JWT has to come from somewhere before it can be decoded — GenKitLab's JWT generator builds signed test tokens with custom claims, useful for exercising an auth flow without a real login.
  • JSON Formatter & Validator — paste a decoded payload here to reformat or validate it once it's out of the token.
  • Unix Timestamp Converter — turn an exp, iat, or nbf claim into a readable date in a specific timezone instead of eyeballing a raw epoch integer.

See the full Security tools category for more utilities that touch tokens, hashing, and encoding.

Frequently asked questions

What is the difference between decoding and verifying a JWT?

Decoding is base64url-decoding the header and payload back into JSON — it requires no key and proves nothing about authenticity; anyone can decode any JWT, including a forged one. Verifying recomputes the signature using a known secret or public key and checks it matches, which is the only operation that actually proves the token wasn't tampered with and was issued by whoever holds the signing key.

Is it safe to trust the claims in a decoded JWT without verifying it?

No. A decoded payload is just JSON that anyone could have written — decoding it doesn't check a signature, so an attacker can hand-craft any sub, role, or aud value they want and send it to an endpoint that only decodes. If the code path that reads a JWT's claims doesn't also call a verify function with a known key and an explicit algorithm, it's trivially bypassable.

What is the alg: none attack in JWTs?

The JWS spec technically allows an "alg": "none" header meaning an unsecured, unsigned token. A verifier that reads the alg field from the token itself and skips the signature check when it says "none" gives an attacker a direct bypass: edit any claims, set alg to none, drop the signature, and a naive verifier accepts it. The fix is for an application to pin its expected algorithm itself, never infer it from the token.

What is JWT algorithm confusion (RS256 vs HS256)?

It's an attack against systems that verify using an algorithm read from the token instead of one pinned server-side. If an attacker knows the RSA public key used to verify RS256 tokens (often published openly), and a verifier trusts a token claiming HS256, it may check the HMAC using that public key as the secret — and since the attacker also knows the public key, they can compute a matching signature and forge a valid-looking token.

Why does my JWT show 'invalid signature' when the payload looks fine?

The signature is computed over the exact bytes of the header and payload segments, so it fails to match if either was edited after signing (even one character), if the wrong secret or key was used to verify, or if the token was hand-crafted without the real signing key. A decoder alone can't catch this — only a verify call against the correct secret does.

How do I check if a JWT is expired?

Decode the payload and compare its exp claim, a Unix timestamp, against the current time — but do this through a verifying library, not by eyeballing the decoded JSON, since a proper jwt.verify() or jwt.decode() call with signature checking enabled rejects an expired token automatically (typically as an ExpiredSignatureError) even when the signature itself is genuinely valid.

Does GenKitLab's JWT decoder verify signatures, or just decode?

Both, as two distinct actions. Decoding reads the header and payload claims with no key required. Verifying checks the signature against a secret you provide for HS256, HS384, or HS512 tokens. Everything runs entirely client-side — nothing pasted into the tool is ever uploaded or logged — and the interface never presents a decoded token as if it were verified.

What claims does a JWT payload usually contain?

RFC 7519 defines seven optional standard claims: iss (issuer), sub (subject), aud (audience), exp (expiration time), iat (issued at), nbf (not before), and jti (a unique token ID). Beyond those, an application can add any custom claims it needs, like role or email — nothing in the spec restricts payload content beyond it being valid JSON.

Last updated