API

How to Decode a JWT Token (And Verify It Correctly)

2026-05-18 · 9 min read · Convert JWT timestamps →

You are staring at a string that looks like this: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoibWFyaWFAZXhhbXBsZS5jb20iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3NDYwMDAwMDAsImV4cCI6MTc0NjA4NjQwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c. It is a JWT — a JSON Web Token — and every API, OAuth flow, and authentication system you work with produces them. This guide explains exactly what a JWT contains, how to decode it in under 30 seconds, what each field means, and how to verify one correctly in code.

What Is a JWT?

A JSON Web Token is a compact, URL-safe token format defined in RFC 7519. It is used to securely transmit claims — statements about a user or system — between parties. JWTs are the standard format for authentication tokens in modern web applications, OAuth 2.0 flows, and API authorisation systems.

The key property that makes JWTs useful: they are self-contained. The token itself carries all the information needed to verify its authenticity and read its claims, without requiring a database lookup on every request. A server can validate a JWT using only a public key or shared secret — no session store, no Redis call, no database query.

The Structure of a JWT

Every JWT has exactly three parts, separated by dots: header.payload.signature. Each part is Base64URL-encoded. The header and payload are just JSON — anyone can decode them. The signature is what makes the token tamper-proof.

Part 1: Header

The header is a JSON object describing the token type and the signing algorithm used.

// Base64URL decode: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
{
  "alg": "HS256",   // Signing algorithm: HMAC-SHA256
  "typ": "JWT"      // Token type
}

Common algorithm values: HS256 (HMAC with SHA-256, shared secret), RS256 (RSA with SHA-256, public/private key pair), ES256 (ECDSA with SHA-256), none (no signature — dangerous, never use in production).

Part 2: Payload (Claims)

The payload contains the claims — the actual data the token carries. Claims are divided into three categories: registered (standardised), public (commonly used conventions), and private (custom to your application).

// Base64URL decode: eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoibWFyaWFAZXhhbXBsZS5jb20iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3NDYwMDAwMDAsImV4cCI6MTc0NjA4NjQwMH0
{
  "sub": "user_123",                // Subject — who the token is about
  "email": "maria@example.com",     // Public claim
  "role": "admin",                  // Private claim (custom)
  "iat": 1746000000,                // Issued At (Unix timestamp)
  "exp": 1746086400                 // Expiration (Unix timestamp)
}

Registered Claim Names You Will See

ClaimFull nameMeaning
subSubjectWho the token is about — typically a user ID
issIssuerWho issued the token — your auth server URL
audAudienceWho the token is intended for — your API URL
expExpirationUnix timestamp after which token is invalid
iatIssued AtUnix timestamp when token was issued
nbfNot BeforeUnix timestamp before which token is invalid
jtiJWT IDUnique identifier for this specific token

Part 3: Signature

The signature is computed over the encoded header and payload using the algorithm specified in the header. For HS256: HMAC-SHA256(base64url(header) + "." + base64url(payload), secret). For RS256: RSA-SHA256(base64url(header) + "." + base64url(payload), privateKey).

The signature is what prevents tampering. If anyone modifies the header or payload, the signature no longer matches, and the token is rejected. Anyone can decode the header and payload — they are not encrypted. Only the signature provides security.

How to Decode a JWT Instantly

Decoding a JWT means reading the header and payload. This requires no key and no secret — you just Base64URL-decode each part. Use ToolPry's Base64 Decoder: paste the header or payload section (everything between the dots), and the decoded JSON appears immediately.

For the expiry time specifically, the exp claim is a Unix timestamp. Paste it into ToolPry's Timestamp Converter to instantly see the human-readable expiry date and time.

Decoding JWTs in Code

JavaScript — decode without verification (inspection only)

function decodeJWT(token) {
  const [header, payload, signature] = token.split('.');

  // Base64URL → Base64 → decode
  const decode = (str) => {
    const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
    const padded = base64 + '=='.slice((base64.length + 3) % 4);  // add padding
    return JSON.parse(atob(padded));
  };

  return {
    header: decode(header),
    payload: decode(payload),
    signature   // raw — cannot be decoded without the key
  };
}

const { header, payload } = decodeJWT(token);
console.log(payload.exp);              // Unix timestamp
console.log(new Date(payload.exp * 1000).toISOString()); // human date
console.log(payload.exp > Date.now() / 1000); // true = not expired

JavaScript — verify and decode (production use)

// npm install jose  (or jsonwebtoken for Node.js only)
import { jwtVerify, importJWK } from 'jose';

const secret = new TextEncoder().encode('your-secret-key'); // HS256
// or for RS256: const publicKey = await importJWK(jwk, 'RS256');

try {
  const { payload } = await jwtVerify(token, secret, {
    issuer: 'https://auth.example.com',    // validates iss claim
    audience: 'https://api.example.com',   // validates aud claim
  });
  console.log(payload.sub);   // user ID
  console.log(payload.email); // email
} catch (err) {
  // JWTExpired, JWTInvalid, JWSSignatureVerificationFailed, etc.
  console.error('Token invalid:', err.code);
}

Python

import jwt  # pip install PyJWT

# Decode WITHOUT verification (inspection only — never trust the claims)
header = jwt.get_unverified_header(token)
payload = jwt.decode(token, options={"verify_signature": False})
print(payload['exp'])

# Decode WITH verification (production use)
try:
    payload = jwt.decode(
        token,
        key='your-secret-key',      # or RSA public key for RS256
        algorithms=['HS256'],
        audience='https://api.example.com',
        issuer='https://auth.example.com',
    )
    print(payload['sub'])
except jwt.ExpiredSignatureError:
    print("Token has expired")
except jwt.InvalidTokenError as e:
    print(f"Invalid token: {e}")

Common JWT Mistakes and Security Issues

Accepting the none algorithm. Some early JWT libraries accepted tokens with "alg": "none" — no signature required. This means an attacker could craft any payload and the library would accept it. Always specify the expected algorithm explicitly when verifying.

Confusing decoding with verification. Decoding reads the claims. Verification checks that the signature is valid. You must verify before trusting any claim in the payload. Never make authorisation decisions based on decoded-but-unverified JWT claims.

Storing sensitive data in the payload. The payload is not encrypted — it is only Base64-encoded. Anyone who intercepts the token can read every claim. Never put passwords, credit card numbers, PII beyond what is necessary, or any data you would not want exposed in a JWT payload.

Long expiry times. JWTs cannot be invalidated server-side without a token blocklist (which defeats part of their purpose). Set short expiry times: 15 minutes for access tokens, 7–30 days for refresh tokens. Use refresh token rotation to maintain sessions securely.

Storing JWTs in localStorage. localStorage is accessible to any JavaScript on the page, making tokens vulnerable to XSS attacks. Store access tokens in memory (a JavaScript variable) and refresh tokens in HttpOnly cookies that JavaScript cannot access.

Checking JWT Expiry Without a Library

function isTokenExpired(token) {
  try {
    const payload = JSON.parse(
      atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))
    );
    if (!payload.exp) return false;  // no expiry claim = does not expire
    return payload.exp < Math.floor(Date.now() / 1000);
  } catch {
    return true; // malformed token = treat as expired
  }
}

// Usage
if (isTokenExpired(accessToken)) {
  await refreshAccessToken();
}

Frequently Asked Questions

Is a JWT encrypted?

Standard JWTs (JWS — JSON Web Signature) are signed but not encrypted. The payload is only Base64URL-encoded, which is trivially reversible. Anyone who obtains the token can read its claims. JWE (JSON Web Encryption) is a separate standard that encrypts the payload — you can identify these because they have five parts instead of three. In practice, most JWTs are JWS tokens transmitted over HTTPS, which provides transport-layer encryption.

How do I invalidate a JWT before it expires?

You cannot invalidate a standard JWT server-side without maintaining state — which partially defeats their purpose. The common approaches: keep a token blocklist (a set of invalidated JTI values), use very short expiry times so the window of misuse is small, or rotate the signing secret (which invalidates all tokens but may not be practical). For logout flows, delete the token client-side and maintain a small server-side blocklist of recently issued tokens for the expected token lifetime.

What is the difference between a JWT and a session cookie?

A session cookie stores a session ID — a random opaque value that the server looks up in a database on every request to retrieve the user's data. A JWT stores the data itself in the token. Session cookies require a session store (database, Redis) but can be invalidated instantly. JWTs require no session store and scale horizontally without shared state, but cannot be easily invalidated before expiry. For most applications, session cookies with a database are simpler and safer; JWTs are advantageous in distributed systems and API contexts where stateless authentication matters.

Why does my JWT have four or five parts instead of three?

A four-part token is unusual and likely malformed. A five-part token is a JWE (JSON Web Encryption) token: header.encrypted_key.iv.ciphertext.tag. JWE tokens encrypt their payload, unlike standard JWTs. You need the private key to decrypt them — Base64 decoding the payload section will produce unreadable binary data, not JSON.

JWT Libraries by Language

Using a well-maintained JWT library is always better than implementing verification yourself. Libraries handle edge cases — algorithm confusion attacks, token format validation, clock skew tolerance, and claim validation — that are easy to get wrong in a custom implementation.

JavaScript/Node.js: jose (by Panva) is the most actively maintained and supports all JWT/JWK/JWE algorithms with both browser and Node.js compatibility. jsonwebtoken (by Auth0) is the most widely deployed but Node.js only. @auth0/nextjs-auth0 wraps JWT handling for Next.js applications.

Python: PyJWT is the standard library, lightweight and well-audited. python-jose supports JOSE standards more broadly including JWK and JWE. For FastAPI, the fastapi-jwt-auth package integrates JWT handling with FastAPI's dependency injection.

Go: golang-jwt/jwt is the most popular, actively maintained fork of the original dgrijalva/jwt-go. Java: jjwt (JJWT) by Okta is the standard. PHP: firebase/php-jwt is widely used and well-maintained.

Reading JWT Claims Without a Library

For debugging and development, you often just need to read the payload without verifying the signature. This is fine for inspection — never for authorisation decisions in production code.

// Browser — one-liner to decode JWT payload
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));

// With padding fix for robustness
function readJWT(token) {
  const b64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
  const padded = b64 + '=='.slice((b64.length + 3) % 4);
  return JSON.parse(atob(padded));
}

// Python — one-liner
import base64, json
payload = json.loads(base64.urlsafe_b64decode(token.split('.')[1] + '=='))

// Read expiry as human date
from datetime import datetime, timezone
exp = datetime.fromtimestamp(payload['exp'], tz=timezone.utc)
print(f"Token expires: {exp.strftime('%Y-%m-%d %H:%M:%S UTC')}")

Debugging JWT Issues in Production

The most common JWT-related production failures and how to diagnose them: 401 Unauthorized with a valid-looking token — check the exp claim first. Convert the Unix timestamp with ToolPry's Timestamp Converter to see if the token has expired. Signature verification failure despite correct secret — check whether the token was signed with HS256 but you are verifying with RS256 or vice versa. The algorithm is in the header. Claims missing from payload — the token may be valid but your auth server is not including the expected claims. Decode the payload and inspect it directly. Clock skew errors — if your server clock is even a few seconds ahead of the issuing server, tokens may appear expired. Use the clockTolerance option in JWT libraries to allow a small window (30–60 seconds is standard).

OAuth 2.0 and JWT Access Tokens

Most JWTs you encounter in production are OAuth 2.0 access tokens. Understanding the OAuth flow helps you know where the token comes from and what to do when it stops working. In a standard OAuth 2.0 flow with PKCE (the recommended pattern for web and mobile apps), your application redirects the user to an authorization server, the user authenticates there, and the authorization server returns an access token and a refresh token to your app.

The access token is a short-lived JWT — typically 15 minutes to 1 hour — containing the user's identity and permissions. Your API receives this token in the Authorization: Bearer [token] header, verifies the signature, checks expiry, and grants or denies access based on the claims. The refresh token is a longer-lived, opaque token (often not a JWT) used to obtain new access tokens without making the user log in again.

When debugging OAuth flows: decode the access token payload to see exactly what claims the authorization server included. Check that the aud (audience) claim matches your API's expected audience — a mismatch causes 401 errors even with a valid, non-expired token. Verify the iss (issuer) matches your authorization server URL. Check scope claims to ensure the token has permission for the requested operation.

For local development, the fastest way to inspect any JWT is to paste it into ToolPry's Base64 Decoder — take the second part (between the two dots) and decode it to read the payload claims. For the expiry specifically, decode the exp value in ToolPry's Timestamp Converter.