Treat the payload as public, rely on the signature for authenticity, and always reject tokens whose exp is past to keep a replayed token from living too long.
Encoded, not encrypted
A JWT is not protected by secrecy; it is three base64url pieces, the header and claims in clear text plus the signature, joined by dots for compactness. Anyone holding the token can decode it and read every claim in plaintext. That is correct by design, and it has a sharp consequence for what you put inside: no secrets, no server credentials, no session material that belongs only to the server. If a value must be readable only by the server, do not embed it, look it up by a jti claim instead. Encoding is a transport step, not confidentiality.
Verify then trust
A JWT works only if the consumer verifies before acting. The correct sequence is to check the signature against a key you trust and recognize as the issuer’s, and only then read the claims and enforce the audience. A verifier that skips the signature or trusts the header’s alg is the classic break-in, because a forged token can claim the none algorithm or a public key and get accepted. So pin the algorithm to a configured set, verify the issuer and audience, and never let the token tell you which key or algorithm to trust. Trust comes from the signature and the key you configured, not from the token’s own claims.
exp is a contract
The signature proves who wrote a token, not how long it is still valid. The exp claim is the lifetime boundary and must be enforced: a token past its exp is rejected even if its signature is perfectly current. Real systems add a small, bounded clock-skew allowance so a just-issued token survives a few seconds of NTP wobble, and keep that skew well under the token’s lifetime. Pair exp with a short lease for anything sensitive, because a token you cannot revoke before it expires will otherwise live until its clock runs out. That expiration is the only thing that stops a stolen or replayed token from outliving its window.
When it breaks
A verifier that honours the token’s alg header invites alg=none forgery. An attacker copies a valid payload, rewrites the header to declare no signature, drops the third segment, and a naive verifier accepts it because it skipped verification. Pin the expected algorithm on the server, reject none, and never let attacker-controlled headers select the verification path.
Key confusion between RS256 and HS256 is the second classic. If one verifier accepts both, an attacker signs a forged token with HS256 using the server’s public RSA key as the HMAC secret. The verifier recomputes the HMAC with that same public value and accepts it. Allow one algorithm family per issuer, store RSA and HMAC keys separately, and treat kid only as a local lookup key.
Missing timestamps fail quietly. Without exp a stolen token works until the signing key rotates; without iat there is no anchor for skew checks. A long lifetime plus no revocation state means logout cannot kill one token alone. Issue short expiries, require exp and iat, carry a jti denylist for logout, and keep secrets out of the payload because base64url is readable.
Check it quickly
Save the following as jwt_check.py and run python3 jwt_check.py with Python 3.11 or newer. It uses only the standard library and implements HS256 by hand with hmac, hashlib, base64, json, and time, so the signature, expiry, and tamper checks stay visible. No third-party packages, network calls, or clock changes are needed: expiry is simulated with a negative lifetime, and tampering flips one payload character.
import base64
import hashlib
import hmac
import json
import time
SECRET = b"demo-secret-key-change-me"
def b64url_encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def b64url_decode(data: str) -> bytes:
padding = "=" * (-len(data) % 4)
return base64.urlsafe_b64decode(data + padding)
def issue(subject: str, lifetime: int = 600) -> str:
header = b64url_encode(
json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode()
)
now = int(time.time())
payload = b64url_encode(
json.dumps(
{"sub": subject, "iat": now, "exp": now + lifetime},
separators=(",", ":"),
).encode()
)
signature = b64url_encode(
hmac.new(SECRET, f"{header}.{payload}".encode(), hashlib.sha256).digest()
)
return f"{header}.{payload}.{signature}"
def verify(token: str) -> dict:
header_b64, payload_b64, signature_b64 = token.split(".")
header = json.loads(b64url_decode(header_b64))
assert header.get("alg") == "HS256", f"unexpected alg: {header.get('alg')}"
expected = b64url_encode(
hmac.new(SECRET, f"{header_b64}.{payload_b64}".encode(), hashlib.sha256).digest()
)
assert hmac.compare_digest(expected, signature_b64), "bad signature"
claims = json.loads(b64url_decode(payload_b64))
assert "exp" in claims and "iat" in claims, "missing exp/iat"
assert int(time.time()) < int(claims["exp"]), "token expired"
return claims
token = issue("demo-user")
claims = verify(token)
assert claims["sub"] == "demo-user"
print(f"valid: sub={claims['sub']}")
stale = issue("demo-user", lifetime=-10)
try:
verify(stale)
raise AssertionError("expired token must not verify")
except AssertionError as exc:
assert str(exc) == "token expired", exc
print("expired correctly rejected: token expired")
head, pay, sig = token.split(".")
tampered_pay = ("A" if pay[0] != "A" else "B") + pay[1:]
try:
verify(f"{head}.{tampered_pay}.{sig}")
raise AssertionError("tampered token must not verify")
except AssertionError as exc:
assert str(exc) == "bad signature", exc
print("tampered correctly rejected: bad signature")
print("PASS: valid accepted; expired and tampered rejected")
The script issues a fresh token and verifies it, proving the happy path. It then issues an already-expired token and confirms verification fails with an expiry error, not a signature error. Finally it tampers with one payload character and confirms verification fails with a signature mismatch. The three assertions therefore cover valid, expired, and forged inputs. Expect four output lines: the valid subject, two rejection lines, and a final pass line.
valid: sub=demo-user
expired correctly rejected: token expired
tampered correctly rejected: bad signature
PASS: valid accepted; expired and tampered rejected
Further reading
Sources
- RFC 7519: JSON Web Token, token structure, header, and registered claims, checked 2026-02-27.
- RFC 8725: JWT Best Current Practices, algorithm pinning, key confusion, and lifetime guidance, checked 2026-02-27.
- OWASP JSON Web Token Cheat Sheet, none algorithm, key management, and validation checklist, checked 2026-02-27.