BytePane

JWT Tokens Guide: Authentication Made Simple

Authentication14 min read

What Is a JSON Web Token (JWT)?

A JSON Web Token (JWT, pronounced "jot") is an open standard (RFC 7519) for securely transmitting information between parties as a compact, URL-safe JSON object. JWTs are the backbone of modern web authentication, used by virtually every API-driven application to verify user identity without maintaining server-side sessions.

Unlike traditional session-based authentication where the server stores session data in memory or a database, JWTs are self-contained. The token itself carries all the information needed to authenticate a user, making it ideal for distributed systems, microservices, and single-page applications where stateless communication is critical.

A JWT looks like a long string of characters separated by two dots. To inspect the contents of any JWT instantly, use our JWT Decoder tool. It decodes the header, payload, and verifies the structure without sending your token to any server.

// A typical JWT (line breaks added for readability):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

// Three parts separated by dots:
// 1. Header  (algorithm + token type)
// 2. Payload (claims / user data)
// 3. Signature (verification hash)

JWT Structure: Header, Payload, and Signature

Every JWT consists of exactly three parts, each Base64url-encoded and joined by dots. Understanding each part is essential for working with JWTs correctly and securely.

1. Header

The header is a JSON object that specifies the signing algorithm and token type. The two most common fields are alg (the algorithm used to sign the token) and typ (always "JWT").

// JWT Header (decoded from Base64url)
{
  "alg": "HS256",   // HMAC with SHA-256
  "typ": "JWT"      // Token type
}

// Common algorithms:
// HS256 - HMAC + SHA-256 (symmetric, shared secret)
// RS256 - RSA + SHA-256 (asymmetric, public/private key)
// ES256 - ECDSA + SHA-256 (asymmetric, smaller keys)
// none  - No signature (NEVER use in production!)

2. Payload (Claims)

The payload contains the claims -- statements about the user and additional metadata. There are three types of claims: registered claims (standardized by RFC 7519), public claims (defined by the application), and private claims (custom data shared between parties).

// JWT Payload (decoded from Base64url)
{
  // Registered claims (standardized, optional but recommended)
  "iss": "https://api.example.com",   // Issuer
  "sub": "user_12345",                // Subject (user ID)
  "aud": "https://app.example.com",   // Audience
  "exp": 1709764800,                  // Expiration (Unix timestamp)
  "nbf": 1709761200,                  // Not Before
  "iat": 1709761200,                  // Issued At
  "jti": "unique-token-id-abc123",    // JWT ID (prevents replay)

  // Custom claims (your application data)
  "name": "John Doe",
  "email": "[email protected]",
  "role": "admin",
  "permissions": ["read", "write", "delete"]
}

The payload is Base64url-encoded, not encrypted. Anyone who intercepts the token can decode and read the payload. Never put sensitive data like passwords, credit card numbers, or social security numbers in a JWT payload. Use our Base64 Encoder/Decoder to see how easily any Base64 string can be reversed.

3. Signature

The signature ensures the token has not been tampered with. It is created by taking the encoded header, the encoded payload, a secret key, and the algorithm specified in the header, then hashing them together.

// How the signature is created (HS256 example):
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret_key
)

// For RS256 (asymmetric):
RSASHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  private_key   // Sign with private key
)
// Verify with public key — this is why RS256 is preferred
// for microservices: only the auth server needs the private key

How JWT Authentication Works

JWT authentication follows a straightforward flow. The user logs in, receives a token, and includes that token with every subsequent request. Here is the complete flow from login to API access.

  1. User sends credentials -- The client sends a username and password to the authentication endpoint (e.g., POST /api/login).
  2. Server validates credentials -- The server checks the credentials against the database. If valid, it creates a JWT with the user's information.
  3. Server returns the JWT -- The signed token is sent back to the client, typically in the response body or a Set-Cookie header.
  4. Client stores the token -- The client saves the JWT (in memory, localStorage, or an HttpOnly cookie) for use in future requests.
  5. Client sends token with requests -- Every API request includes the JWT, usually in the Authorization header as a Bearer token.
  6. Server verifies the token -- The server checks the signature, expiration, and claims. If valid, the request is processed.
// Step 1: Login request
const response = await fetch('/api/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: '[email protected]', password: 's3cure!' })
});
const { accessToken, refreshToken } = await response.json();

// Step 2: Use the token for authenticated requests
const data = await fetch('/api/profile', {
  headers: {
    'Authorization': `Bearer ${accessToken}`
  }
});

// Step 3: Server-side verification (Node.js + jsonwebtoken)
const jwt = require('jsonwebtoken');

app.get('/api/profile', (req, res) => {
  const token = req.headers.authorization?.split(' ')[1];
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    // decoded = { sub: "user_12345", name: "John", iat: ..., exp: ... }
    res.json({ user: decoded });
  } catch (err) {
    res.status(401).json({ error: 'Invalid or expired token' });
  }
});

Access Tokens vs Refresh Tokens

A robust JWT authentication system uses two types of tokens: short-lived access tokens for API authorization and long-lived refresh tokens for obtaining new access tokens without re-authenticating.

PropertyAccess TokenRefresh Token
Lifetime5-15 minutes7-30 days
PurposeAuthorize API requestsGet new access tokens
StorageMemory or short-lived cookieHttpOnly secure cookie
Sent toResource servers (APIs)Auth server only
RevocableNot easily (expires naturally)Yes (stored in database)
// Token refresh flow
async function fetchWithAuth(url, options = {}) {
  let accessToken = getAccessToken();

  let response = await fetch(url, {
    ...options,
    headers: { ...options.headers, Authorization: `Bearer ${accessToken}` }
  });

  // If access token expired, use refresh token to get a new one
  if (response.status === 401) {
    const refreshResponse = await fetch('/api/token/refresh', {
      method: 'POST',
      credentials: 'include'  // sends HttpOnly cookie
    });

    if (refreshResponse.ok) {
      const { accessToken: newToken } = await refreshResponse.json();
      setAccessToken(newToken);

      // Retry the original request with the new token
      response = await fetch(url, {
        ...options,
        headers: { ...options.headers, Authorization: `Bearer ${newToken}` }
      });
    } else {
      // Refresh token expired — redirect to login
      redirectToLogin();
    }
  }

  return response;
}

Signing Algorithms: HS256 vs RS256 vs ES256

Choosing the right signing algorithm is one of the most important JWT decisions. The algorithm determines how the signature is created and verified, which affects security, performance, and architecture.

HS256 (HMAC + SHA-256) -- Symmetric

Uses a single shared secret for both signing and verification. Simple to set up, but every service that needs to verify tokens must have access to the secret. Best for monolithic applications where a single server handles both signing and verification.

// HS256: Same secret for signing and verifying
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET; // min 256 bits (32 chars)

// Sign
const token = jwt.sign({ sub: 'user_123' }, SECRET, {
  algorithm: 'HS256',
  expiresIn: '15m'
});

// Verify (same secret)
const decoded = jwt.verify(token, SECRET);

RS256 (RSA + SHA-256) -- Asymmetric

Uses a private key to sign and a public key to verify. The public key can be shared freely, so any service can verify tokens without ever having the ability to create them. This is the recommended algorithm for microservices and distributed systems.

// RS256: Private key signs, public key verifies
const fs = require('fs');
const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');

// Auth server signs with private key
const token = jwt.sign({ sub: 'user_123' }, privateKey, {
  algorithm: 'RS256',
  expiresIn: '15m'
});

// Any service verifies with public key (safe to distribute)
const decoded = jwt.verify(token, publicKey, {
  algorithms: ['RS256']  // ALWAYS specify allowed algorithms!
});

ES256 (ECDSA + SHA-256) -- Asymmetric

Uses Elliptic Curve cryptography. Provides the same security as RS256 with much smaller key sizes (256-bit vs 2048-bit), resulting in smaller tokens and faster operations. Increasingly preferred for new applications and mobile environments.

AlgorithmTypeKey SizeBest For
HS256Symmetric256-bit secretMonolith apps
RS256Asymmetric2048-bit RSAMicroservices, enterprise
ES256Asymmetric256-bit ECMobile, modern apps

Where to Store JWTs: Security Tradeoffs

Token storage is the most debated aspect of JWT security. Each storage option comes with specific attack vectors, and the right choice depends on your application architecture.

Option 1: HttpOnly Cookies (Recommended)

Storing JWTs in HttpOnly, Secure, SameSite cookies is the most secure option for web applications. JavaScript cannot access HttpOnly cookies, which completely prevents XSS attacks from stealing tokens. The main risk is CSRF, which is mitigated by the SameSite attribute and CSRF tokens.

// Server sets the JWT as an HttpOnly cookie
res.cookie('access_token', token, {
  httpOnly: true,     // JavaScript cannot read this cookie
  secure: true,       // Only sent over HTTPS
  sameSite: 'strict', // Not sent with cross-site requests
  maxAge: 15 * 60 * 1000,  // 15 minutes
  path: '/'
});

Option 2: In-Memory (Most Secure, But Limited)

Storing the access token only in JavaScript memory (a variable) means it survives neither page refreshes nor tab closures. This is the most secure option against XSS because the token is never persisted, but it requires a refresh token (in an HttpOnly cookie) to re-authenticate after every page load.

Option 3: localStorage / sessionStorage (Common But Risky)

While convenient, storing JWTs in localStorage exposes them to any JavaScript running on the page. A single XSS vulnerability lets an attacker steal the token and impersonate the user from any device. Avoid this in applications handling sensitive data.

StorageXSS SafeCSRF SafePersists
HttpOnly CookieYesWith SameSiteYes
In-MemoryYesYesNo
localStorageNoYesYes
sessionStorageNoYesTab only

JWT Security Best Practices

JWTs are secure when implemented correctly, but common mistakes can introduce severe vulnerabilities. Follow these best practices to protect your users and your application.

  1. Always verify the signature -- Never decode a JWT without verifying the signature first. Trusting unverified tokens is equivalent to having no authentication.
  2. Set short expiration times -- Access tokens should expire in 5-15 minutes. Use refresh tokens for longer sessions. Short-lived tokens limit the damage window if a token is compromised.
  3. Use strong secrets -- For HS256, use at least 256 bits (32 characters) of randomness. Never use simple strings like "secret" or "password".
  4. Specify the algorithm explicitly -- Always pass the expected algorithm when verifying. The alg: "none" attack exploits libraries that trust the header's algorithm claim.
  5. Validate all claims -- Check exp, iss, aud, and nbf claims. An expired token or a token issued for a different audience should always be rejected.
  6. Never put sensitive data in the payload -- The payload is only encoded, not encrypted. Treat it as public information.
  7. Implement token revocation -- Maintain a blocklist of revoked token IDs (jti) for critical actions like logout, password change, or account compromise.
  8. Use HTTPS exclusively -- JWTs transmitted over HTTP can be intercepted. Always enforce TLS/HTTPS in production.
// Secure JWT verification example
const jwt = require('jsonwebtoken');

function verifyToken(token) {
  try {
    return jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'],           // Whitelist allowed algorithms
      issuer: 'https://api.myapp.com', // Validate issuer
      audience: 'https://myapp.com',   // Validate audience
      clockTolerance: 30,              // 30 sec clock skew tolerance
    });
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      // Token expired — trigger refresh flow
    } else if (err.name === 'JsonWebTokenError') {
      // Invalid token — reject request
    }
    return null;
  }
}

Common JWT Vulnerabilities

Understanding the most exploited JWT vulnerabilities helps you build defenses against them. These are real-world attacks that have compromised production systems.

1. Algorithm None Attack

An attacker modifies the header to set "alg": "none" and removes the signature. If the server does not enforce a specific algorithm, it may accept the unsigned token as valid. The fix is to always specify allowed algorithms during verification.

2. Key Confusion (RS256 to HS256)

If a server is configured for RS256 but does not enforce the algorithm, an attacker can change the header to HS256 and sign the token using the RSA public key (which is public) as the HMAC secret. Some JWT libraries will then verify the HMAC signature using the public key, accepting a forged token.

3. Token Sidejacking

Tokens stored in localStorage can be stolen via XSS. Even a minor cross-site scripting vulnerability lets attackers execute localStorage.getItem('token') and exfiltrate the JWT to their server. Use HttpOnly cookies to prevent this entirely.

4. Expired Token Replay

If the server does not validate the exp claim, expired tokens remain valid forever. Always check expiration, and use the jti claim with a server-side revocation list for tokens that should be invalidated before their natural expiration.

Inspect suspicious tokens with our JWT Decoder to check their claims, expiration, and algorithm. You can also use the JSON Formatter to prettify the decoded payload for easier analysis.

Building a Complete JWT Auth System

Here is a production-ready JWT authentication system using Node.js and Express. It demonstrates secure token generation, verification middleware, and the refresh token flow.

const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const crypto = require('crypto');

const app = express();
app.use(express.json());

const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
const refreshTokens = new Map(); // Use Redis in production

// Generate tokens
function generateTokens(user) {
  const accessToken = jwt.sign(
    { sub: user.id, email: user.email, role: user.role },
    ACCESS_SECRET,
    { algorithm: 'HS256', expiresIn: '15m', issuer: 'myapp' }
  );

  const refreshToken = jwt.sign(
    { sub: user.id, jti: crypto.randomUUID() },
    REFRESH_SECRET,
    { algorithm: 'HS256', expiresIn: '7d', issuer: 'myapp' }
  );

  return { accessToken, refreshToken };
}

// Login endpoint
app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await findUserByEmail(email);

  if (!user || !await bcrypt.compare(password, user.passwordHash)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const { accessToken, refreshToken } = generateTokens(user);
  refreshTokens.set(refreshToken, user.id);

  res.cookie('refresh_token', refreshToken, {
    httpOnly: true, secure: true, sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000
  });

  res.json({ accessToken });
});

// Auth middleware
function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'No token' });

  try {
    req.user = jwt.verify(token, ACCESS_SECRET, {
      algorithms: ['HS256'], issuer: 'myapp'
    });
    next();
  } catch {
    res.status(401).json({ error: 'Invalid token' });
  }
}

// Protected route
app.get('/api/profile', authenticate, (req, res) => {
  res.json({ userId: req.user.sub, email: req.user.email });
});

Frequently Asked Questions

Can someone decode my JWT without the secret key?

Yes. The header and payload are only Base64url-encoded, not encrypted. Anyone can decode and read them. The secret key is only needed to verify the signature (proving the token has not been tampered with) and to create new valid tokens. Never put sensitive information in the payload.

How do I invalidate a JWT before it expires?

JWTs are stateless by design, so there is no built-in way to revoke them. The most common approaches are: maintaining a server-side blocklist of revoked token IDs (jti), using very short expiration times (5-15 minutes) so tokens expire quickly, or changing the signing key (which invalidates all tokens). Each approach trades off between security and complexity.

Should I use JWT or session-based authentication?

JWTs are best for stateless APIs, microservices, and mobile applications where you cannot easily share server-side sessions. Session-based authentication is simpler and inherently revocable, making it better for traditional server-rendered applications. Many production systems use a hybrid approach: JWT for API authorization and server-side sessions for the authentication layer.

What happens when a JWT expires?

When a JWT's exp claim timestamp is in the past, any properly configured server will reject it with a 401 Unauthorized response. The client should then use its refresh token to request a new access token. If the refresh token is also expired, the user must log in again.

Is it safe to put user roles and permissions in a JWT?

Yes, it is common to include roles and basic permissions in the JWT payload for quick authorization checks without database queries. However, remember that these claims are readable by anyone who has the token. For fine-grained permissions that change frequently, query the database instead of relying solely on JWT claims.

Decode and Inspect JWTs Instantly

Paste any JWT into our free decoder to inspect its header, payload, and claims. Check expiration times, verify structure, and debug authentication issues. Everything runs locally in your browser -- your tokens never leave your device.

Open JWT Decoder

Related Articles