What is the difference between decoding and verifying a JWT?

Dark navy cover card with the title 'What is the difference between decoding and verifying a JWT?' in white and a green accent bar

The difference between JWT decode vs verify is trust. Decoding turns the token's header and payload back into readable JSON, and it needs no key. Verifying checks the signature with a key, then checks the algorithm, expiry, issuer and audience, and only then hands you a payload you can act on.

If your code makes an access decision from a decoded payload, an attacker can write whatever claims they like.

What decoding a JWT does

Decoding a JWT means splitting the token at its dots and base64url-decoding the first two parts into JSON. That is all it does. No key, no signature check, no clock check.

A signed JWT has three dot-separated parts. The first is the header ({"alg":"HS256","typ":"JWT"}), the second is the claims set, and the third is the signature. The JWT spec, RFC 7519, describes this base64url step in its validation section, but decoding stops long before validation ends.

Library docs are blunt about it. The node-jsonwebtoken README says jwt.decode "Returns the decoded payload without verifying if the signature is valid" and warns you not to use it for untrusted messages. The jose decodeJwt docs say it works "without validating its JWS Signature (digital signature or MAC) or claim types and values."

If you want to see why no secret is needed, read can a JWT be decoded without the secret.

What verifying a JWT checks

Verifying a JWT means proving the token was signed by a key you trust, with an algorithm you allow, and that its claims fit your application right now. A verify function rejects the whole token if any of those checks fails.

  1. Signature. The signature must match the header and payload under your key. RFC 8725, the JWT best-practices document, says all cryptographic operations "MUST be validated and the entire JWT MUST be rejected if any of them fail to validate."
  2. Algorithm. You pass the list of algorithms you accept. RFC 8725 says libraries "MUST enable the caller to specify a supported set of algorithms" and must not use any others.
  3. Expiry (exp) and not-before (nbf). RFC 7519 says the current time must be before exp and on or after nbf, with "some small leeway, usually no more than a few minutes, to account for clock skew."
  4. Issuer (iss). RFC 8725 says the application must check that the signing keys belong to the issuer named in the token.
  5. Audience (aud). If the token has an aud claim and your service isn't in it, RFC 7519 says the JWT "MUST be rejected."

Steps 4 and 5 only happen if you tell the library what issuer and audience to expect. A signature check alone doesn't stop a valid token that was meant for another service.

JWT decode vs verify at a glance

The table below is the comparison most guides skip: each check, whether decoding does it, and how three common libraries handle it on the verify side.

Check Decode node-jsonwebtoken jwt.verify jose jwtVerify PyJWT jwt.decode with a key
Reads header and payload Yes Yes (returns payload) Yes Yes
Signature No Yes, fails with invalid signature Yes Yes
Allowed algorithms No algorithms option Yes algorithms=["HS256"]
exp No Yes, fails with jwt expired Yes Yes, jwt.ExpiredSignatureError
nbf No Yes, fails with jwt not active Yes Yes
iss No Only with the issuer option When you pass an issuer jwt.InvalidIssuerError on a mismatch
aud No Only with the audience option When you pass an audience jwt.InvalidAudienceError on a mismatch

The node-jsonwebtoken error strings come straight from its README. Its clockTolerance option is the "number of seconds to tolerate when checking the nbf and exp claims", which is the leeway RFC 7519 allows. The jose jwtVerify docs describe it as validating both the signature and "its JWT Claims Set."

The PyJWT naming trap

PyJWT's jwt.decode() is the verify call. According to the PyJWT usage docs, "Expiration time is automatically verified in jwt.decode()". To only read the claims, you pass options={"verify_signature": False}, and the docs call that "generally ill-advised" unless you know what you're doing.

In Node, decode skips the checks. In Python, decode runs them. Read the function's docs, not its name.

Why trusting a decoded payload is dangerous

Trusting a decoded payload is dangerous because the payload is just text the client sent you. Without the signature check, nothing stops someone from changing "role":"user" to "role":"admin" and re-encoding it.

The algorithm header is a second trap. RFC 8725 describes attacks where the alg value can be changed to "none" and some libraries then trusted the token without checking any signature. Passing an explicit algorithms list closes that hole.

Picture an Express middleware that sets req.user = jwt.decode(token) and then checks req.user.isAdmin. Every request with a home-made token passes. Replacing that line with jwt.verify(token, publicKey, { algorithms: ["RS256"], issuer, audience }) turns a forged token into a JsonWebTokenError instead of an admin session.

When decoding alone is fine

Decoding without verifying is fine when you are reading, not deciding. The payload can shape what you show or log, but never what you allow.

Good uses:

  • Debugging. A request fails, so you read the token's exp, aud and iss to see why.
  • Picking the key. You decode the header first to read kid, look up that key, then verify with it.
  • Client-side display. Your front end reads the user's name from a token your own app just received, for the greeting. The server still verifies on every API call.

For the debugging case, a paste-and-read tool works, as long as it keeps the token local. Our JWT decoder shows the header, payload and signature, plus the iat and exp times with an expired or valid flag. It only decodes: it does not verify the signature, so "valid" there means "not expired yet", not "trustworthy." For which tokens are safe to paste anywhere, see how to decode a JWT safely and whether online JWT debuggers are safe to paste into.

My take, and the limit of verification

My take: keep the bare decode function out of server request handlers entirely. Ban it there with a lint rule and allow it only in tooling, tests and the key-lookup step. A decode call added "just for now" is easy to forget and hard to spot in review.

Verification has a real limit. It proves who signed the token and that it hasn't expired. It can't tell you the token wasn't stolen, or that the user wasn't removed after the token was issued. For that you still need short token lifetimes or a server-side check.

FAQ

Does decoding a JWT check the signature?

No. Decoding only base64url-decodes the header and payload so you can read them. Functions such as jwt.decode in node-jsonwebtoken and decodeJwt in jose say plainly that they do not validate the signature.

Do I need the secret or public key to decode a JWT?

No. A signed JWT's header and payload are only encoded, not encrypted, so anyone can decode them. You need the key only to verify the signature.

Does verify also decode the token?

Yes. A verify function checks the signature and the claims, then returns the decoded payload. If you call verify, you don't need a separate decode call.

Is it ever fine to decode a JWT without verifying it?

Yes, for reading: debugging a token, showing a user name in the UI from a token your own client received, or reading the header's kid to pick the right key before you verify. It is not fine for any decision about access.

Sources