DevKitLab Logo DevKitLab
JWT / Authentication / Debugging / Security

Why Is My JWT Invalid? How to Decode and Debug a JSON Web Token

A 401 that says 'invalid token' rarely means the token is garbled — it means one specific check between you and the server failed. Instead of collecting decode tricks, learn what a JWT actually is: signed plaintext you can always read but should only trust after verifying. Then every failure becomes a short, ordered checklist.

Your request comes back 401 with a body that says, unhelpfully, {"error":"invalid_token"}. You have the token right there in the Authorization header — a long, opaque-looking string with two dots in it — and no idea what’s wrong with it. So you start guessing: regenerate it, bump the expiry, try a different secret, restart the auth service. Each attempt is a shot in the dark, because the error told you that something failed, not which thing.

Here’s the reframing that ends the guessing: that string is not opaque, and it is not encrypted. You can read every byte of what it claims right now, without a key, without the server’s help. A JWT is signed plaintext — the payload is sitting in plain view behind a reversible encoding, and the only part nobody can forge without the key is the signature that proves who wrote it. Once you internalize that one fact, “why is my token invalid?” stops being a mystery and becomes a short, ordered question: of the handful of independent checks the server runs, which one said no?

This article isn’t a bag of decode tricks. It spends a few minutes on what a JWT actually is — the two questions it answers and keeps strictly separate — and then walks a single token through every way it can be rejected, from the boring (it expired) to the subtle (the whitespace in your JSON broke the signature) to the dangerous (the header told the server which algorithm to trust, and the header is attacker-controlled). By the end you’ll have a checklist you can run against any 401.

The one idea: decoding and trusting are two different questions

Almost every JWT confusion traces back to conflating two questions that the standard deliberately keeps apart:

  1. What does this token say? — Always answerable. Anyone holding the token can read its contents. No key required.
  2. Should I believe what it says? — Only answerable with the key — in practice, by whichever service holds it, usually the one receiving the request.

A JWT is built to make question 1 trivial and question 2 rigorous. The contents are merely encoded — a reversible transform anyone can undo — while a cryptographic signature rides along to answer question 2. The signature doesn’t hide anything; it doesn’t scramble the payload. It’s a tamper-evident seal: change one character of the contents and the seal no longer matches, but you can read the contents whether the seal matches or not.

This is why “is a JWT secure?” is a trick question. The payload is about as private as a postcard — the mail carrier can read it — but it’s as tamper-evident as a wax seal: you’ll know if someone rewrote it. Hold onto this split. Every section below is really about one side or the other: reading the token (question 1) or the reasons the server refused to trust it (question 2).

The shape: three Base64URL segments, split on the dots

Take the token apart before anything else. A compact JWT is exactly three chunks joined by two dots:

header . payload . signature

Each of the first two is a Base64URL-encoded JSON object. Split on the dots, decode the first two segments, and you’re looking at plain JSON:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiJ1c2VyXzQyIiwibmFtZSI6IkFkYSIsImV4cCI6MTcwMDAwMDAwMH0 . 3Rf...

The header decodes to something like {"alg":"HS256","typ":"JWT"} — the type, and crucially the signing algorithm. The payload decodes to your claims, e.g. {"sub":"user_42","name":"Ada","exp":1700000000}. The signature is raw bytes, also Base64URL’d, and it is not JSON — don’t try to read it as text; it’s the output of a hash or a signing operation.

One caveat to “you can always read it”: that holds for a signed token — a JWS in compact form, which is what you meet almost everywhere. Encrypted JWE tokens also exist; their compact form has five segments (four dots) rather than three, the payload genuinely is opaque without the key, and they’re out of scope here. So if a “JWT” won’t split into three readable segments, suspect a JWE — or a plain truncation or formatting problem — before you keep trying to decode it.

Now the first real pitfall, and it bites people who decode by hand or reach for the wrong helper: Base64URL is not Base64. It’s a variant designed to be URL- and header-safe, and it differs in three ways that will silently corrupt your decode:

  • + becomes -, and / becomes _ — so the two characters most likely to appear in a hash are exactly the ones that differ.
  • The trailing = padding is stripped. A standard Base64 decoder often requires that padding and will throw without it.
  • There are no line breaks.

That’s why atob(segment) in a browser, or a naive base64 -d on the command line, will usually throw on a token that’s perfectly valid — the - and _ are invalid characters in the standard alphabet — and a decoder lenient enough not to throw hands you the wrong bytes instead. Either way they’re speaking plain Base64 at a Base64URL string. You have to swap -/_ back to +// and re-pad to a multiple of four before a standard decoder will cooperate. If you want to see this concretely, take a single segment and run it through a Base64 encoder/decoder in URL-safe mode versus standard mode — the standard mode will choke or produce garbage on the same input the URL-safe mode reads cleanly. This one mismatch is the most common reason a “decode it myself” attempt produces nonsense and sends someone down the wrong debugging path entirely. (That Base64URL-versus-standard mismatch — and every other reason a Base64 string refuses to decode — gets its own walk-through.)

The claims that actually get you rejected

Assume you’ve decoded the payload cleanly. Most invalid_token rejections aren’t cryptographic at all — they’re a claim the server checked and didn’t like. These are the registered claims from RFC 7519, and a few of them are refusal machines:

  • exp (expiration) — the number one reason, by a wide margin. It’s a Unix timestamp in seconds; after that instant the token must be rejected. Almost every library enforces this automatically, so an expired token fails before your code ever sees it.
  • nbf (not before) — the mirror image. The token isn’t valid yet. You’ll hit this when a token is minted for future use, or — far more often — when the signing server’s clock runs ahead of the verifying server’s.
  • iat (issued at) — when it was created. Not usually a rejection trigger on its own, but the anchor for reasoning about age and clock drift.
  • aud (audience) — who the token is for. If your API is https://api.example.com and the token’s aud names a different service, a correct verifier rejects it even though the signature is perfect. Tokens minted for one service getting replayed at another is exactly what aud exists to stop. A concrete version you meet in OIDC: an ID token (its aud is your client app) sent to an API that expects an access token (whose aud is the API) is a perfectly genuine token used in the wrong place — right signature, wrong audience.
  • iss (issuer) — who minted it. Verifiers check it against an allow-list of trusted issuers, and use it to find the issuer’s published key set — its JWKS endpoint. (Which individual key inside that set gets used is decided by the kid in the header, not by iss.)

The trap that hides inside all the time-based claims: exp, nbf, and iat are epoch seconds, not milliseconds. JavaScript’s Date.now() gives you milliseconds, so a hand-rolled comparison that forgets to divide by 1000 will read every token as expired roughly 54,000 years from now — or reject every fresh token as already dead, depending on which side you got wrong. And because the value is a bare integer like 1700000000, your eyes can’t tell at a glance whether a token expired last week or expires next year. This is precisely where a tool earns its keep: paste the token into the JWT inspector and exp, iat, and nbf render as real, human-readable dates, with the absolute time on hover — so “did it expire?” and “are the two servers’ clocks in sync?” become things you see instead of arithmetic you do in your head. Clock skew in particular is nearly invisible in raw epoch integers and obvious the moment they’re dates side by side.

The signature: it’s for verifying, and it can’t be “decoded”

Here’s where the two-questions split gets sharp. People say “decode the signature” — but the signature isn’t encoded information you can reverse into something readable. It’s the output of running the header and payload through a keyed operation, and the only thing you can do with it is recompute it and check for a match. You verify a signature; you never decode one.

What that operation is depends on the alg in the header, and the split runs down a fault line worth understanding:

  • HS256 / HS384 / HS512 — symmetric (HMAC). The signature is HMAC-SHA256(secret, header.payload). The same shared secret both signs and verifies. Simple, fast, and with a sharp edge: anyone who can verify an HS256 token can also forge one, because verifying and signing use the identical key. HMAC here is exactly the HMAC-SHA256 primitive you’d use for webhook signatures or API request signing — a JWT signature is that same digest with a standardized input format.
  • RS256 / ES256 / PS256 / EdDSA — asymmetric. The issuer signs with a private key; everyone else verifies with the matching public key. This is the real reason large systems prefer RS256: the auth server holds the private key and no one else can mint tokens, while a hundred downstream services can each verify with a freely-distributed public key and still be unable to forge anything. If you want to feel the shape of that keypair, generate one with the RSA key generator — the private key signs, the public key verifies, and they are not interchangeable.

On the asymmetric side there’s one more moving part that causes a surprising share of real outages. The verifier usually doesn’t hold the public key directly — it fetches the issuer’s JWKS, a published set of keys, and the header’s kid says which key in that set signed this token. Issuers rotate those keys periodically. If your service caches the JWKS and a rotation happens, the kid on new tokens can point at a key you haven’t fetched yet — and every token starts failing verification at once, even though nothing about the tokens is wrong. When a whole fleet’s tokens go invalid simultaneously, suspect key rotation and a stale JWKS cache before you suspect the tokens.

The critical consequence, and the whole point of this section: decoding the payload proves nothing about trust. You can read a token’s claims perfectly and the signature can still be worthless — wrong key, no key, tampered contents. A good inspector is honest about this distinction. When you paste a token without a key, the JWT inspector shows its status as “Decoded, not verified” — deliberately not green, not a checkmark — precisely so you don’t mistake “I can read it” for “I can trust it.” Only after you supply the matching secret or public key does it move to “Signature verified.” That label discipline is the two-questions idea made visible.

The subtle one: the signature covers the exact bytes, not the JSON

This trips up even people who understand everything above, so it’s worth its own beat. The signature isn’t computed over “the header and payload as data structures” — it’s computed over the exact Base64URL text of the first two segments, character for character, including the dot between them. The signing input is literally the ASCII string base64url(header) + "." + base64url(payload).

Which means: if you decode a payload, pretty-print the JSON, re-encode it, and glue the old signature back on — the token is now invalid, even though you changed nothing semantically. Re-ordering keys, adding whitespace, or a decoder that re-serializes {"a":1} as { "a": 1 } all produce different bytes, and different bytes mean a different signature. This is by design — it’s what makes the seal tamper-evident — but it explains a genuinely baffling failure mode: “I only reformatted it and now it won’t verify.” You didn’t change the meaning, but you changed the bytes, and the signature only ever promised something about the bytes. It’s also why you can’t hand-edit a JWT: to change one claim honestly you must re-sign, which requires the key. (That’s what the inspector’s sign mode is for — edit the JSON, provide the key, get a genuinely re-signed token rather than a broken splice.)

The dangerous one: the header tells the server how to trust it

Now the part that turns a debugging article into a security one, and seeds where this series goes next. Look again at where alg lives: in the header. Which is part of the token. Which is supplied by whoever sent the token. The instruction for how to verify trust is sitting inside the very thing whose trust is in question.

Two classic attacks fall straight out of that:

  • alg: none. The spec defines an “unsecured” JWT with no signature at all. A naive verifier that reads alg from the header and does what it says will see "alg":"none", skip signature checking entirely, and accept a token an attacker wrote by hand. The fix is that the server must decide which algorithms are acceptable, from its own configuration — never take alg as an instruction from the token.
  • RS256 → HS256 confusion. If a server verifies “whatever alg says” and holds an RSA public key, an attacker can craft a token with alg switched to HS256 and sign it using that public key as the HMAC secret. The public key is public — so the attacker has everything needed to forge. The library, told to do HMAC, dutifully verifies. The fix, again: pin the expected algorithm server-side; don’t let the token choose.

To keep this in proportion: mainstream libraries have defended against both for years. They reject alg: none unless you go out of your way to allow it, and they make you name the algorithms you accept up front rather than trusting the token’s own alg. So this isn’t cause to panic about every JWT — it’s cause to distrust hand-rolled verifiers, long-outdated libraries, and any configuration that feeds the token’s own alg into the list of accepted algorithms.

The through-line is the same as the whole article. The token can say anything — alg, exp, sub, admin: true — because the payload and header are just plaintext anyone can write. Safety comes entirely from the server refusing to treat the token’s own claims as instructions, and from a signature check it controls. This is the seam the next article in this series pries open: why you must never trust a payload you haven’t verified, and every way that trust gets bypassed. Everything above was “how to read a token”; that’s “how to not get fooled by one.”

The checklist for an actual 401

Next time a token gets rejected, don’t regenerate blindly. Run this in order — it isolates almost every case:

  1. Read it first. Paste the token into the JWT inspector and look at the decoded header and payload. Don’t guess what’s inside — see it. Most of the time the answer is right here in a claim.
  2. Check exp (and nbf). Are they in the past / future? Read them as dates, not integers, and compare the signing and verifying clocks — skew is the quiet culprit. Remember: seconds, not milliseconds.
  3. Check aud and iss. Does the audience name your service, and is the issuer one your server trusts? A perfect signature still fails these.
  4. Check the transport. Is there a stray Bearer prefix, trailing whitespace, or a newline that got copied into the header value? The token that verifies in a tool can fail over the wire because of one invisible character.
  5. Verify the signature deliberately. Supply the matching secret (HS) or public key (RS/ES/EdDSA) and confirm it moves from “Decoded, not verified” to “Signature verified.” If it won’t, the usual causes are: wrong key, the alg in the header not matching your key family, or the bytes were altered after signing (see the reformatting trap above). On RS/ES/EdDSA, add one more suspect: a rotated signing key behind a stale JWKS cache — if the header’s kid no longer resolves to a key you hold, verification fails for reasons that have nothing to do with this particular token.
  6. Confirm alg is what you expect — and that your server pins it rather than reading it from the token. If it says none, or an HMAC algorithm where you expected RSA, that’s not a bug to route around; it’s a red flag.

Two rules make all six stick. Reading a token needs no key; trusting one always does — keep those apart and half the confusion evaporates. And because the payload is readable by anyone, never put a secret in it — a JWT is signed, not encrypted, so treat every claim as public. On RS/ES tokens the verifying public key most often comes from the issuer’s JWKS, but sometimes you’re handed it as an X.509 certificate instead — and when you are, you can read that certificate’s subject and validity with the certificate decoder. (Reading a certificate tells you what it contains, not that its issuer belongs on your trust list — that decision stays yours.) But it all starts the same way: stop treating the token as an opaque blob you can only regenerate, and start reading the plaintext that was in front of you the whole time.