If you work with any modern authentication system — OAuth, Auth0, Firebase, or your own backend API — you have almost certainly encountered a JWT. They appear in HTTP headers, local storage, and API responses, but what exactly is inside one, and how do you read it safely?

What is a JWT?

JWT stands for JSON Web Token. It is a compact, URL-safe string used to securely transmit information between two parties. The information is encoded — not encrypted — so it can be read by anyone who has the token.

JWTs are most commonly used for authentication and authorization. After a user logs in, the server issues a JWT. The client stores it and sends it with every subsequent request. The server verifies the token's signature to confirm it is legitimate — without needing to query a database on every request.

JWT is an open standard defined in RFC 7519. It is widely supported across languages and frameworks — Node.js, Python, Java, PHP, Ruby, Go, and more all have mature JWT libraries.

The structure of a JWT

A JWT is three Base64URL-encoded strings joined by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Header . Payload . Signature

Each part is independently Base64URL encoded. The dots are separators — not part of the encoded data. This three-part structure is what makes a JWT immediately recognisable: it always contains exactly two dots.

Part 1 — Header

The header is a small JSON object describing the token type and the algorithm used to create the signature:

{
  "alg": "HS256",  // signing algorithm: HS256, RS256, ES256
  "typ": "JWT"    // token type
}

Common signing algorithms are HS256 (HMAC with SHA-256, uses a shared secret key) and RS256 (RSA with SHA-256, uses a public/private key pair). RS256 is preferred in distributed systems because the public key can verify tokens without exposing the private signing key.

Part 2 — Payload

The payload contains the actual data — called claims. These are key-value statements about the user and the token itself:

{
  "sub": "1234567890",      // subject — usually the user ID
  "name": "John Doe",
  "email": "[email protected]",
  "role": "admin",          // custom claim
  "iat": 1516239022,        // issued at — Unix timestamp
  "exp": 1516242622,        // expiry — Unix timestamp
  "iss": "https://api.example.com", // issuer
  "aud": "https://app.example.com"   // audience
}

JWT defines two types of claims:

  • Registered claims — standardised names with defined meanings: sub, iat, exp, iss, aud, nbf (not before), jti (JWT ID)
  • Custom claims — anything your application needs, such as role, permissions, or tenantId

The payload is not encrypted. It is only Base64URL encoded. Anyone who holds the token can decode and read every claim. Never store passwords, payment details, or sensitive personal data in a JWT payload.

Part 3 — Signature

The signature is a cryptographic hash produced by the server at the time the token is issued:

// For HS256 (symmetric — shared secret)
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secretKey
)

// For RS256 (asymmetric — private key signs, public key verifies)
RSASHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  privateKey
)

The signature is what makes JWTs trustworthy. Anyone can read the header and payload, but only the party that holds the secret key (HS256) or private key (RS256) can produce a valid signature. If someone modifies even a single character in the payload, the signature will no longer match and the server will reject the token.

Why do JWT tokens always start with "ey"?

This is one of the most commonly asked questions about JWTs — and the answer is purely mechanical.

The header of every JWT is a JSON object that begins with {". When you Base64URL encode the string {", the result always starts with ey. Since every JWT header begins with a JSON curly brace and a quote, every encoded header starts with ey.

// The header always begins with {"
btoa('{"alg":"HS256","typ":"JWT"}')
// → "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
//    ^^
//    always "ey" — Base64 of '{"'

// The payload also begins with {"
btoa('{"sub":"123",...}')
// → "eyJzdWIiOiIxMjMiLC4uLn0="
//    ^^
//    also starts with "ey"

This is why a JWT always looks like eyX… dot eyX… dot randomString. The first two parts both start with ey because both the header and payload are JSON objects starting with {". The third part (the signature) is raw binary data encoded as Base64URL, so it starts with whatever the hash produces.

This predictable prefix is useful for detection. If you are scanning logs or request payloads for accidentally leaked tokens, searching for the pattern ey[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+ will catch most JWTs.

How to decode a JWT

Decoding a JWT means reading the header and payload. It requires no secret key — you are simply reversing the Base64URL encoding. This is useful for inspecting claims, checking expiry times, and debugging authentication issues.

In JavaScript, the built-in atob() function handles standard Base64. JWT uses Base64URL, which replaces + with - and / with _ and removes padding. A robust decode function handles this:

function decodeJWT(token) {
  const parts = token.split('.');
  if (parts.length !== 3) throw new Error('Invalid JWT');

  function base64UrlDecode(str) {
    // Replace Base64URL chars with standard Base64
    str = str.replace(/-/g, '+').replace(/_/g, '/');
    // Add padding if needed
    while (str.length % 4) str += '=';
    return JSON.parse(atob(str));
  }

  return {
    header:  base64UrlDecode(parts[0]),
    payload: base64UrlDecode(parts[1]),
    // Signature is binary — not parsed
  };
}

// Usage
const decoded = decodeJWT(myToken);
console.log(decoded.payload.sub);   // user ID
console.log(decoded.payload.exp);   // expiry as Unix timestamp
console.log(decoded.payload.role);  // custom claim

// Check if token is expired
const isExpired = decoded.payload.exp < Math.floor(Date.now() / 1000);

Decoding vs verifying — a critical distinction

These two operations are completely different and the distinction matters for security:

  • Decoding — reads the payload by reversing Base64URL encoding. Anyone can do this. No secret key needed. Does not confirm the token is legitimate.
  • Verifying — checks the cryptographic signature to confirm the token was issued by a trusted server and has not been tampered with. Requires the secret key (HS256) or public key (RS256). Must happen on the server.
// Node.js — NEVER use decode() for authorization
const jwt = require('jsonwebtoken');

// decode() — reads claims, no validation at all
const claims = jwt.decode(token);     // unsafe for authorization

// verify() — checks signature AND expiry
const claims = jwt.verify(token, secretKey); // throws if invalid

When you use a JWT decoder tool to inspect a token, you are decoding — not verifying. The claims are readable, but that tells you nothing about whether the token is genuine. A client-side decoder is for inspection and debugging only. Authorization decisions must always use server-side verification.

Is it safe to paste a JWT into a decoder tool?

The safety depends entirely on how the tool works. Two things to check before pasting any token:

  • Does the tool run in the browser? If yes, the token is decoded locally using JavaScript. Nothing leaves your device. Safe to use.
  • Does the tool send data to a server? If yes, you are transmitting the token to a third party. Never paste a production JWT into a tool that makes server requests.

For tokens containing real user data or authorization claims, use a local tool or decode it manually. Treat a JWT like a password — something you do not share with third-party services.

Decode a JWT token instantly

Paste any JWT and see the decoded header, payload claims, and expiry time. Runs entirely in your browser — nothing is ever sent to a server.

Open JWT Decoder →