← All posts

Authentication

JWT decode vs verify: why decoding skips the signature check

A decoded JWT and a verified JWT look identical in your code. Only one of them actually proves the token wasn't forged — and mixing them up is a complete authentication bypass.

By Prbl Security Team

A JSON Web Token has three parts: a header, a payload, and a cryptographic signature. The payload is just base64-encoded JSON — not encrypted, just encoded — so anyone can read it without a secret key. Decoding a JWT does exactly that: it reads the payload. Verifying a JWT does something different: it checks the signature against your secret key to confirm the token was actually issued by your server and wasn't modified in transit.

Most JWT libraries expose both operations separately. In the Node.js jsonwebtoken package, that’s jwt.decode() and jwt.verify(). They take similar arguments and return similar values. The difference is that decode() requires no secret key at all and performs no signature check. If your authentication middleware calls decode() where it should call verify(), anyone can forge a token with any claims they want and be treated as authenticated.

What the attack looks like

Say your API route checks the JWT in an Authorization header to decide whether a user is an admin:

// VULNERABLE: decode() trusts the payload unconditionally
import jwt from 'jsonwebtoken';

export async function middleware(req) {
  const token = req.headers.authorization?.split(' ')[1];
  const payload = jwt.decode(token); // no secret, no signature check
  if (payload?.role === 'admin') {
    return next(); // an attacker passes this check trivially
  }
}

An attacker constructs a JWT by hand with {"role": "admin", "userId": "anyone"} in the payload, signs it with a random key (or no key), and sends it. Because decode() never checks the signature, the middleware reads the payload, sees role: admin, and grants access. No brute force, no exploit, no valid credentials needed.

Scan your own app for issues like these

Paste your live URL. We check what your app serves publicly for exposed keys and misconfigurations. No account, no install.

The correct pattern: always use verify()

// CORRECT: verify() checks the signature against your secret
import jwt from 'jsonwebtoken';

export async function middleware(req) {
  const token = req.headers.authorization?.split(' ')[1];
  try {
    // throws if signature is invalid, token is expired, or alg doesn't match
    const payload = jwt.verify(token, process.env.JWT_SECRET!, {
      algorithms: ['HS256'], // always pin the expected algorithm
    });
    if (payload.role === 'admin') {
      return next();
    }
  } catch {
    return res.status(401).json({ error: 'Unauthorized' });
  }
}

Always pin the algorithms option. Without it, a library may accept whatever algorithm the token’s own header claims, which enables the alg:none attack: an attacker sets the algorithm to none, removes the signature entirely, and some libraries will accept it.

Library-specific patterns to watch for

The vulnerable pattern appears across every major language and library:

// Node.js — jsonwebtoken
jwt.decode(token)        // ❌ no verification
jwt.verify(token, secret, { algorithms: ['HS256'] }) // ✅

// Node.js — jose
import { decodeJwt } from 'jose'  // ❌ decode only, no verification
import { jwtVerify } from 'jose'  // ✅

const { payload } = await jwtVerify(token, secret, {
  algorithms: ['HS256'],
});

// Python — PyJWT
jwt.decode(token, options={"verify_signature": False}) # ❌ explicitly skips check
jwt.decode(token, secret, algorithms=["HS256"])         # ✅

// The jwt-decode npm package
import { jwtDecode } from 'jwt-decode' // ❌ decode-only by design, never use for auth

The jwt-decode npm package is a common trap. It has no verify function — it is intentionally decode-only, designed for reading claims in UI components after the server has already verified the token. Using it for server-side authentication decisions is a guaranteed vulnerability.

When decode() is actually fine

There are legitimate uses for decoding without verifying, but they are all display-only scenarios where no access control decision is being made:

  • Reading the user’s name or avatar URL to display in the UI (after the server already verified the session)
  • Reading the token’s exp claim client-side to show a “session expires in X minutes” warning
  • Debugging: inspecting what a token contains without a secret key
  • Token introspection endpoints that read the header to route to the right verification key (but must still verify afterward)

The rule: if the result of reading a JWT claim influences what a user can access, the token must be verified, not decoded. If it only influences what is displayed, decoding may be acceptable.

Why this appears in AI-generated auth code

Decode-only JWT handling is one of the most common authentication flaws we find in AI-built apps. AI tools generate the happy path first — they wire up login, get a token flowing, and read it with whichever function is easiest to call. decode() requires no secret and never throws, so it works immediately in development. The verification step either never gets added, or it gets added to one route handler but not middleware, or it gets disabled with { verify_signature: false } to silence an error and never re-enabled.

The consequence is a complete authentication bypass: every protected route becomes unprotected. An attacker does not need to steal a valid token — they can mint any token they want.

Want to see what a token actually contains? Paste one into our free JWT decoder — it decodes the header and payload in your browser and shows exactly why decoding proves nothing about whether the token is genuine.

If you found jwt.decode() in your codebase, the fix is in hardcoded JWT secret — that page covers rotating the secret and wiring verify() correctly. The broader auth checklist is in securing a vibe-coded app.

Common questions

What is the difference between jwt.decode and jwt.verify?

jwt.decode() reads the base64-encoded payload without checking the cryptographic signature — it requires no secret key and trusts the token unconditionally. jwt.verify() checks the signature against your secret key and throws if the token was tampered with or expired. Only verify() proves a token is genuine.

Is jwt-decode safe to use for authentication?

No. The jwt-decode npm package only decodes — it intentionally has no verify function. It is useful for reading claims in a UI (for display purposes, after the server has already verified the token), but it must never be used to make authentication or authorization decisions. Use jsonwebtoken, jose, or a similar library that performs signature verification on the server.

Can I use jwt.decode on the client side?

Yes, but only for display purposes — for example, reading the user's name or expiry time to show in the UI. Never use a decoded (unverified) JWT claim to make access control decisions, even on the server. All authentication checks must use verify(), and they must happen on the server.

What is the alg:none JWT attack?

The alg:none attack exploits libraries that accept a token's own header to determine how to verify it. An attacker sets the algorithm field to 'none', removes the signature, and sends the token. A vulnerable library sees alg:none, skips signature verification, and accepts the forged token. Always pin the expected algorithm in your verify call and never trust the algorithm from the token header.

How do I fix decode-only JWT handling in Next.js?

Replace any call to jwtDecode() or jwt.decode() in middleware or API routes with jwt.verify() from the jsonwebtoken package or jose's jwtVerify(). Pass your signing secret and pin the expected algorithm. Never use the jwt-decode package for server-side auth decisions — it has no verify function by design.

Does jwt.verify check expiration?

Yes. By default, jsonwebtoken's verify() rejects tokens where the exp claim is in the past. You can disable this with { ignoreExpiration: true }, but doing so in authentication middleware is a security mistake — an attacker with a stolen token can use it indefinitely.

Ready to check your own app?

Paste your live URL. We check what your app serves publicly for exposed keys and misconfigurations. No account, no install.

Or see a live example scan first.

Prbl in one place: SAST for AI code · secret scanner · website vulnerability scanner · AI code review · GitHub security scanner · OWASP Top 10 · open dataset

jwt.decode() Does Not Verify the Signature — Use jwt.verify() | Prbl