Backend Security Lesson 3 of 6 ⏱️ 4 min read

JWT Explained: Structure, Signing & Pitfalls

How JSON Web Tokens work, how to sign and verify them, access vs refresh tokens, and the mistakes that make JWTs insecure.

Updated Oct 30, 2026

What is a JWT?

A JSON Web Token (JWT) is a compact, signed token that carries claims — statements such as "this is user 42" and "this token expires at 10:15". The server signs it; later, anyone with the right key can verify the token wasn't changed.

JWTs are popular for APIs, mobile apps and service-to-service authentication because the server can verify a request without looking up a session in a database.

Structure: three parts

A JWT is three Base64URL-encoded parts separated by dots:

Plaintext
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsImV4cCI6MTc5MzAwMDAwMH0.4Xb9...
└──────── header ────────┘ └──────────── payload ────────────┘ └ signature ┘
  • Header — the algorithm, e.g. {"alg": "HS256", "typ": "JWT"}
  • Payload — the claims, e.g. {"sub": "42", "exp": 1793000000}
  • Signature — a cryptographic signature of the header and payload

Important: the payload is encoded, not encrypted. Anyone holding the token can read it. Never put passwords, personal data or secrets in a JWT.

Standard claims

Claim Meaning
sub Subject — usually the user ID
exp Expiry time (seconds since epoch)
iat Issued-at time
iss Issuer — who created the token
aud Audience — who the token is meant for
jti Unique token ID (useful for revocation lists)

Signing and verifying in Python

Bash
pip install pyjwt
Python
import os
import secrets
from datetime import datetime, timedelta, timezone

import jwt

# Load from the environment in real apps; generated here so the example runs.
SECRET = os.environ.get("JWT_SECRET") or secrets.token_urlsafe(32)
ISSUER, AUDIENCE = "noalabs-api", "noalabs-web"

def create_access_token(user_id: int) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": str(user_id),
        "iat": now,
        "exp": now + timedelta(minutes=15),
        "iss": ISSUER,
        "aud": AUDIENCE,
    }
    return jwt.encode(payload, SECRET, algorithm="HS256")

def verify_access_token(token: str) -> dict:
    # Always pin the allowed algorithms and check issuer + audience.
    return jwt.decode(token, SECRET, algorithms=["HS256"], issuer=ISSUER, audience=AUDIENCE)

token = create_access_token(42)
print(verify_access_token(token)["sub"])          # 42

tampered = token[:-4] + ("AAAA" if not token.endswith("AAAA") else "BBBB")
try:
    verify_access_token(tampered)
except jwt.InvalidTokenError as exc:
    print("rejected:", type(exc).__name__)        # rejected: InvalidSignatureError

jwt.decode checks the signature and exp, iss and aud. Changing even one character of the token makes verification fail.

HS256 vs RS256

HS256 (HMAC) RS256 / ES256 (asymmetric)
Keys One shared secret signs and verifies Private key signs, public key verifies
Best for A single service that issues and checks its own tokens Many services verifying tokens from one auth server
Risk Every verifier can also forge tokens Only the auth server can issue tokens

Identity providers publish their public keys at a JWKS URL so APIs can verify RS256/ES256 tokens.

Access tokens and refresh tokens

  • Access token — short-lived (5–15 minutes), sent with each API request in Authorization: Bearer <token>.
  • Refresh token — long-lived, used only to get a new access token; store it securely (an HttpOnly cookie or the device's secure storage), keep a server-side record, and rotate it on every use so a stolen one is detected.

Short access-token lifetimes limit the damage of a leaked token, because JWTs can't easily be revoked before they expire.

JWT vs server-side sessions

JWT Server session
Server lookup per request No Yes (DB/Redis)
Instant logout / revocation Hard (needs a deny-list or short expiry) Easy — delete the session
Good for APIs, mobile apps, microservices Traditional web apps
Token size Larger (claims + signature) Small random ID

For a classic server-rendered website, sessions are usually simpler and safer. JWTs shine across services and devices.

JWT pitfalls that cause real breaches

  • Accepting alg: none or not pinning algorithms — always pass algorithms=[...] explicitly.
  • Algorithm confusion — an RS256 public key used as an HS256 secret. Pinning the algorithm prevents it.
  • Weak secrets like "secret" — use at least 256 random bits.
  • No expiry, or week-long access tokens.
  • Storing tokens in localStorage, where XSS can read them.
  • Trusting claims without verifying the signature (for example, using a "decode" function that skips verification).

Key takeaways

  • A JWT is a signed, readable (not secret) set of claims: header, payload, signature.
  • Verify signature, expiry, issuer and audience — and pin the algorithm.
  • Keep access tokens short-lived; rotate long-lived refresh tokens.
  • Prefer server-side sessions for simple web apps; use JWTs where stateless verification helps.
Finished this lesson?
Tick it off to track your Backend Security progress.
Next lesson