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.
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:
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
pip install pyjwt
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
HttpOnlycookie 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: noneor not pinning algorithms — always passalgorithms=[...]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.