Toolmingo
Guides10 min read

JWT Explained: JSON Web Tokens from Scratch

Learn how JSON Web Tokens (JWT) work, how to create and verify them in Node.js, Python, and Go, when to use them, and the security pitfalls to avoid.

A JSON Web Token (JWT) is a compact, URL-safe token that carries a signed JSON payload. It lets a server issue credentials that any service can verify without querying a database — which is why JWTs show up in nearly every modern authentication system.


How a JWT Is Structured

A JWT looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjMiLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MTcwMDAwMzYwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Three Base64url-encoded parts separated by dots:

HEADER.PAYLOAD.SIGNATURE

Header

{
  "alg": "HS256",
  "typ": "JWT"
}

alg is the signing algorithm. Common values:

Algorithm Type Notes
HS256 HMAC-SHA256 Symmetric — same secret signs and verifies
HS384 HMAC-SHA384 Symmetric, longer hash
RS256 RSA-SHA256 Asymmetric — private key signs, public key verifies
ES256 ECDSA-SHA256 Asymmetric, smaller keys than RSA
none No signature Never use in production

Payload (Claims)

{
  "sub": "123",
  "name": "Alice",
  "role": "admin",
  "iat": 1700000000,
  "exp": 1700003600
}

Registered claims (standardised, optional but recommended):

Claim Meaning
sub Subject — who the token is about (user ID)
iss Issuer — who created the token
aud Audience — who should accept the token
exp Expiry timestamp (Unix seconds)
iat Issued-at timestamp
nbf Not-valid-before timestamp
jti JWT ID — unique token identifier

Add any custom fields you need (email, role, permissions).

Important: The payload is Base64url-encoded, not encrypted. Anyone who holds the token can read it. Never put passwords, PII you want hidden, or secrets in the payload.

Signature

HMACSHA256(
  base64url(header) + "." + base64url(payload),
  secret
)

The signature proves the token was issued by someone who holds the secret (HS*) or private key (RS*, ES*). If even one byte of header or payload changes, verification fails.


Quick reference

Task One-liner
Decode payload (no verify) atob(token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/'))
Inspect online Paste token at jwt.io
Check expiry exp claim is Unix timestamp — compare to Date.now()/1000
Sign with HS256 (Node) jwt.sign(payload, secret, { expiresIn: '1h' })
Verify (Node) jwt.verify(token, secret)
Sign with RS256 jwt.sign(payload, privateKey, { algorithm: 'RS256' })
Decode without verify (Python) jwt.decode(token, options={"verify_signature": False})

JWT in Node.js

Install

npm install jsonwebtoken

Sign a token

import jwt from 'jsonwebtoken';

const SECRET = process.env.JWT_SECRET; // minimum 32 random bytes

const token = jwt.sign(
  {
    sub: user.id,
    email: user.email,
    role: user.role,
  },
  SECRET,
  {
    expiresIn: '1h',   // '15m', '7d', 3600 (seconds)
    issuer: 'my-api',
    audience: 'my-frontend',
  }
);

Verify a token

try {
  const payload = jwt.verify(token, SECRET, {
    issuer: 'my-api',
    audience: 'my-frontend',
  });
  console.log(payload.sub); // "123"
} catch (err) {
  if (err.name === 'TokenExpiredError') {
    // handle expired
  } else if (err.name === 'JsonWebTokenError') {
    // handle invalid signature / malformed
  }
}

Express middleware

function requireAuth(req, res, next) {
  const auth = req.headers.authorization;
  if (!auth?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token' });
  }
  try {
    req.user = jwt.verify(auth.slice(7), SECRET);
    next();
  } catch {
    res.status(401).json({ error: 'Invalid or expired token' });
  }
}

app.get('/profile', requireAuth, (req, res) => {
  res.json({ userId: req.user.sub });
});

Refresh token pattern

// Issue both tokens at login
function issueTokens(userId) {
  const accessToken = jwt.sign({ sub: userId }, SECRET, { expiresIn: '15m' });
  const refreshToken = jwt.sign({ sub: userId }, REFRESH_SECRET, { expiresIn: '7d' });
  return { accessToken, refreshToken };
}

// Refresh endpoint
app.post('/auth/refresh', (req, res) => {
  const { refreshToken } = req.body;
  try {
    const payload = jwt.verify(refreshToken, REFRESH_SECRET);
    const accessToken = jwt.sign({ sub: payload.sub }, SECRET, { expiresIn: '15m' });
    res.json({ accessToken });
  } catch {
    res.status(401).json({ error: 'Invalid refresh token' });
  }
});

JWT in Python

Install

pip install PyJWT

Sign and verify

import jwt
from datetime import datetime, timedelta, timezone

SECRET = "your-secret-key"

# Sign
payload = {
    "sub": "123",
    "email": "alice@example.com",
    "role": "admin",
    "iat": datetime.now(timezone.utc),
    "exp": datetime.now(timezone.utc) + timedelta(hours=1),
}
token = jwt.encode(payload, SECRET, algorithm="HS256")

# Verify
try:
    decoded = jwt.decode(token, SECRET, algorithms=["HS256"])
    print(decoded["sub"])
except jwt.ExpiredSignatureError:
    print("Token expired")
except jwt.InvalidTokenError:
    print("Invalid token")

FastAPI dependency

from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt

security = HTTPBearer()

def get_current_user(
    credentials: HTTPAuthorizationCredentials = Depends(security),
):
    try:
        payload = jwt.decode(
            credentials.credentials,
            SECRET,
            algorithms=["HS256"],
        )
        return payload
    except jwt.PyJWTError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid or expired token",
        )

@app.get("/me")
def me(user = Depends(get_current_user)):
    return {"userId": user["sub"]}

RS256 with key pair

from pathlib import Path

private_key = Path("private.pem").read_bytes()
public_key  = Path("public.pem").read_bytes()

# Sign with private key
token = jwt.encode(payload, private_key, algorithm="RS256")

# Verify with public key (can be distributed freely)
decoded = jwt.decode(token, public_key, algorithms=["RS256"])

JWT in Go

package main

import (
    "fmt"
    "time"
    "github.com/golang-jwt/jwt/v5"
)

var secret = []byte("your-secret-key")

type Claims struct {
    UserID string `json:"sub"`
    Role   string `json:"role"`
    jwt.RegisteredClaims
}

// Sign
func signToken(userID, role string) (string, error) {
    claims := Claims{
        UserID: userID,
        Role:   role,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
        },
    }
    return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
}

// Verify
func verifyToken(tokenStr string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
        if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
        }
        return secret, nil
    })
    if err != nil {
        return nil, err
    }
    claims, ok := token.Claims.(*Claims)
    if !ok || !token.Valid {
        return nil, fmt.Errorf("invalid token")
    }
    return claims, nil
}

The full authentication flow

Client                          Server
  |                               |
  |-- POST /login (email+pass) -->|
  |                               |-- verify credentials
  |                               |-- issue accessToken (15m)
  |                               |-- issue refreshToken (7d, store in DB)
  |<-- { accessToken, refreshToken } --|
  |                               |
  |-- GET /api/data               |
  |   Authorization: Bearer <accessToken>
  |                               |-- jwt.verify(accessToken, SECRET)
  |<-- 200 { data }              |
  |                               |
  |   (accessToken expires)       |
  |-- POST /auth/refresh          |
  |   { refreshToken }           |
  |                               |-- jwt.verify(refreshToken, REFRESH_SECRET)
  |                               |-- check refreshToken exists in DB
  |                               |-- issue new accessToken
  |<-- { accessToken }           |

Where to store tokens

Storage location XSS risk CSRF risk Notes
localStorage High None Accessible to any JS on the page
sessionStorage High None Cleared on tab close, still XSS-exposed
Memory (JS variable) Low None Lost on refresh; good for short-lived access tokens
httpOnly cookie None Medium Best for refresh tokens; not readable by JS
httpOnly + SameSite=Strict None Low Best overall for server-rendered apps

Recommended:

  • Access token → memory (JavaScript variable)
  • Refresh token → httpOnly; Secure; SameSite=Strict cookie

JWT vs Session tokens

JWT Session
Storage Client holds the token Server stores session in DB/cache
Scalability Stateless — no DB lookup Requires shared session store
Revocation Hard — must wait for expiry or maintain denylist Instant — delete from store
Payload size Grows with claims Just a random ID
Best for Microservices, APIs, mobile Traditional web apps, when instant logout matters
Standard RFC 7519 Implementation-specific

Use JWT when:

  • Building a stateless REST API
  • Multiple services need to verify identity without a central DB call
  • Clients are mobile apps or SPAs

Use sessions when:

  • You need instant revocation (financial apps, security-sensitive)
  • The payload would be large
  • You're building a server-rendered web app

JWT vs OAuth 2.0

These are not alternatives — they solve different problems:

JWT OAuth 2.0
What it is Token format Authorization protocol
What it does Encodes and signs claims Defines how to delegate access
Used together? Yes — OAuth 2.0 often issues JWTs JWT is a common OAuth 2.0 token format

OAuth 2.0 answers "how does the server issue the token?" JWT answers "what format should the token be?"


Security best practices

Algorithm:

// Always specify the algorithm explicitly
jwt.verify(token, secret, { algorithms: ['HS256'] });
// If you omit algorithms, an attacker can forge tokens using alg:"none"

Secret strength:

# Generate a strong secret
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
openssl rand -hex 64

Short expiry for access tokens:

// Access token: 5-15 minutes
{ expiresIn: '15m' }

// Refresh token: days to weeks, stored in DB so it can be revoked
{ expiresIn: '7d' }

Validate all claims:

jwt.verify(token, secret, {
  algorithms: ['HS256'],
  issuer: 'my-api',        // rejects tokens from other issuers
  audience: 'my-frontend', // rejects tokens meant for others
});

Token rotation on refresh:

// When issuing a new access token from a refresh token:
// 1. Verify the refresh token
// 2. Delete old refresh token from DB
// 3. Issue new refresh token + new access token
// This detects refresh token theft via refresh token rotation

Revocation / denylist:

// Store revoked JTI values in Redis with TTL = token expiry
async function isRevoked(token) {
  const { jti } = jwt.decode(token);
  return await redis.exists(`revoked:${jti}`);
}

async function revokeToken(token) {
  const { jti, exp } = jwt.decode(token);
  const ttl = exp - Math.floor(Date.now() / 1000);
  await redis.setex(`revoked:${jti}`, ttl, '1');
}

Common mistakes

Mistake Problem Fix
alg: "none" accepted Forged tokens bypass signature check Always pass algorithms array to verify
Storing access token in localStorage XSS can steal token Use httpOnly cookie or memory
No expiry (exp claim) Stolen tokens valid forever Always set expiresIn
Long-lived access tokens (days) Wide window if stolen Use 15m access + refresh token pattern
Putting secrets in payload Payload is readable by anyone Payload is Base64, not encrypted
Using same secret for access + refresh Compromise of one compromises both Use separate secrets
Not validating iss and aud Tokens from other systems accepted Always validate registered claims
Storing refresh token in localStorage XSS can steal and reuse it Use httpOnly cookie only

Debugging a JWT

# Decode payload without verifying (shows structure)
echo "eyJ..." | cut -d'.' -f2 | base64 -d 2>/dev/null | python3 -m json.tool

# Node.js
node -e "console.log(JSON.stringify(require('jsonwebtoken').decode('TOKEN'), null, 2))"

# Python
python3 -c "
import jwt, sys
t = sys.argv[1]
print(jwt.decode(t, options={'verify_signature': False}))
" YOUR_TOKEN_HERE

# Check expiry
node -e "
const p = JSON.parse(Buffer.from('PAYLOAD_PART', 'base64url'));
console.log('exp:', new Date(p.exp * 1000));
console.log('expired:', p.exp < Date.now()/1000);
"

FAQ

Q: Can I invalidate a JWT before it expires?

Not without extra infrastructure. JWTs are stateless by design. The standard approaches are: (1) keep access tokens short-lived (15m), (2) maintain a Redis denylist of revoked JTI values, or (3) use opaque tokens instead of JWTs where instant revocation is required.

Q: Is the payload encrypted? Can users read it?

No. The payload is Base64url-encoded, not encrypted. Anyone with the token can decode and read the payload. If you need confidentiality, use JWE (JSON Web Encryption) or don't put sensitive data in the payload.

Q: What's the difference between HS256 and RS256?

HS256 uses a single symmetric secret — the same key signs and verifies. RS256 uses an RSA key pair — the private key signs (kept secret on the auth server), the public key verifies (can be distributed to any service). Use RS256 when multiple independent services need to verify tokens without sharing a secret.

Q: How big can a JWT get?

JWTs travel in HTTP headers, which have a practical limit of ~8KB depending on the server. Keep payloads small — user ID, role, and a few claims. Don't embed full user profiles or permission lists.

Q: Should I use JWT for sessions in a web app?

It depends. For a traditional server-rendered web app where instant logout matters, session cookies with a database/Redis backend are simpler and safer. For a stateless REST API or microservices, JWT is a better fit. The "JWT everywhere" pattern is often overkill.

Q: What happens if my JWT secret leaks?

Rotate immediately: generate a new secret, redeploy the auth server, and invalidate all existing tokens. Because JWTs are stateless, there's no way to invalidate existing tokens without a denylist — so users will need to log in again. This is why short-lived access tokens matter.

Related tools

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools