Encoding, decoding, and verifying JSON Web Tokens — access and refresh token patterns, algorithm confusion attacks, revocation limits, and the unverified-claims trap.
JWT (sillo.helpers.jwt)
Section titled “JWT (sillo.helpers.jwt)”A JSON Web Token is a signed, self-describing credential. The server issues one at login; the client sends it back on every request; the server verifies the signature and reads the user’s identity out of the token itself, with no database lookup.
That last part is the entire appeal and the entire trade-off. No lookup means a stateless API that scales horizontally without shared session storage. No lookup also means you cannot take a token back before it expires. Every JWT design decision flows from that one fact.
uv add pyjwtfrom sillo.helpers import jwtThis module wraps PyJWT with sillo-shaped defaults and its own exception types. If you are building full authentication rather than handling tokens directly, JWT Authentication sits on top of this and does more for you.
What is actually in a token
Section titled “What is actually in a token”Three base64url segments joined by dots: header.payload.signature.
token = jwt.create_access_token({"sub": "42"}, "your-secret-key")# eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsImV4cCI6MTc4NTE3Nzc1OX0.xK3...The header names the algorithm. The payload holds the claims. The signature covers the first two.
Registered claims that carry meaning:
| Claim | Meaning | Set by |
|---|---|---|
sub | Subject — who the token is about | You |
exp | Expiry, as a Unix timestamp | create_access_token |
iat | Issued at | create_access_token |
nbf | Not valid before | You |
aud | Intended audience | You |
iss | Issuer | create_access_token(issuer=...) |
jti | Unique token ID, for revocation lists | You |
Creating tokens
Section titled “Creating tokens”create_access_token(data, secret, expires_delta=None, algorithm="HS256", issuer=None)
Section titled “create_access_token(data, secret, expires_delta=None, algorithm="HS256", issuer=None)”The function you will use most. Adds exp and iat automatically:
from datetime import timedelta
token = jwt.create_access_token( {"sub": "42", "role": "admin"}, secret=settings.SECRET_KEY, expires_delta=timedelta(minutes=15),)
jwt.decode(token, settings.SECRET_KEY)# {'sub': '42', 'role': 'admin', 'exp': 1785177759, 'iat': 1785176859}create_refresh_token(data, secret, expires_delta=None, algorithm="HS256")
Section titled “create_refresh_token(data, secret, expires_delta=None, algorithm="HS256")”The same thing with a much longer default lifetime:
refresh = jwt.create_refresh_token({"sub": "42"}, settings.SECRET_KEY)# exp is 7 days out rather than minutesencode(payload, secret, algorithm="HS256", headers=None)
Section titled “encode(payload, secret, algorithm="HS256", headers=None)”The unopinionated version. Adds nothing — no exp, no iat:
jwt.encode({"sub": "42"}, settings.SECRET_KEY)sign(payload, secret, algorithm="HS256", headers=None) -> bytes
Section titled “sign(payload, secret, algorithm="HS256", headers=None) -> bytes”Identical to encode but returns bytes rather than str. Useful when writing straight to a socket or a binary protocol. Everywhere else, encode saves you a .decode().
Verifying tokens
Section titled “Verifying tokens”decode(token, secret, algorithms=None, options=None, audience=None, issuer=None, leeway=0)
Section titled “decode(token, secret, algorithms=None, options=None, audience=None, issuer=None, leeway=0)”Verifies the signature, checks the expiry, and returns the claims. This is the only function that should ever gate access to anything.
payload = jwt.decode(token, settings.SECRET_KEY)user_id = payload["sub"]Failures raise, and the two cases are distinguishable:
from sillo.helpers.jwt import ExpiredTokenError, InvalidTokenError_, TokenError
try: payload = jwt.decode(token, settings.SECRET_KEY)except ExpiredTokenError: # Valid signature, past its expiry. The client should refresh. return response.json({"error": "Token expired", "code": "token_expired"}, status_code=401)except InvalidTokenError_: # Bad signature, malformed, or wrong algorithm. Do not hint at which. return response.json({"error": "Invalid token"}, status_code=401)Both inherit from TokenError, so except TokenError catches either.
The distinction matters to clients. token_expired means “refresh and retry automatically”. invalid means “log the user out”. A client that cannot tell them apart either retries forever on a broken token or logs users out every fifteen minutes.
leeway allows a few seconds of clock skew between the issuing and verifying machines:
jwt.decode(token, secret, leeway=10) # tolerate 10s of driftWithout it, a server whose clock runs slightly ahead rejects tokens it issued moments earlier. Keep leeway small — 10 to 30 seconds. It extends the life of every expired token by that amount.
verify(token, secret, algorithms=None) -> bool
Section titled “verify(token, secret, algorithms=None) -> bool”A boolean check with no claims and no exception:
jwt.verify(token, settings.SECRET_KEY) # Truejwt.verify(token, "wrong-secret") # FalseConvenient for a health check or a quick filter. Do not use it in an auth path — you lose the reason for failure, so you cannot tell a client to refresh.
Reading without verifying
Section titled “Reading without verifying”Three functions read a token while skipping signature verification.
jwt.get_unverified_header(token) # {'alg': 'HS256', 'typ': 'JWT'}jwt.get_unverified_claims(token) # {'sub': '42', 'role': 'admin', ...}jwt.decode_without_verification(token) # same claimsvalidate_claims(payload, audience=None, issuer=None, leeway=0) -> bool
Section titled “validate_claims(payload, audience=None, issuer=None, leeway=0) -> bool”Checks exp, nbf, aud, and iss on a payload you already hold:
payload = jwt.decode(token, secret)jwt.validate_claims(payload, audience="my-api", issuer="auth.example.com")It validates claims, not signatures. Running it on the output of get_unverified_claims gives you nothing — an attacker who forged the claims also forged the aud and iss.
The access and refresh pattern
Section titled “The access and refresh pattern”The standard answer to “tokens cannot be revoked” is to make them short-lived, and to pair them with a longer-lived refresh token that is checked against the database.
from datetime import timedeltafrom sillo.helpers.jwt import create_access_token, create_refresh_token
@app.post("/login")async def login(request, response): body = await request.json user = await authenticate(body["email"], body["password"]) if not user: return response.json({"error": "Invalid credentials"}, status_code=401)
refresh = create_refresh_token({"sub": str(user.id)}, settings.SECRET_KEY) await RefreshToken.create(user=user, token=refresh, revoked=False)
return response.json({ "access_token": create_access_token( {"sub": str(user.id), "role": user.role}, settings.SECRET_KEY, expires_delta=timedelta(minutes=15), ), "refresh_token": refresh, "token_type": "bearer", })@app.post("/refresh")async def refresh_token(request, response): body = await request.json try: payload = jwt.decode(body["refresh_token"], settings.SECRET_KEY) except TokenError: return response.json({"error": "Invalid refresh token"}, status_code=401)
# The stateful check that makes revocation possible at all. stored = await RefreshToken.get_or_none(token=body["refresh_token"], revoked=False) if stored is None: return response.json({"error": "Refresh token revoked"}, status_code=401)
return response.json({ "access_token": create_access_token( {"sub": payload["sub"]}, settings.SECRET_KEY, expires_delta=timedelta(minutes=15), ) })Access tokens stay stateless and fast: verify the signature, no query. Refresh tokens hit the database once every fifteen minutes per user, which is cheap, and give you a revocation point. Logging out marks the refresh token revoked; the access token still works until it expires, which is why fifteen minutes matters.
Algorithms
Section titled “Algorithms”HS256 is the default: one secret, used for both signing and verification. That is right when the same service does both.
jwt.encode(payload, secret, algorithm="HS512")jwt.decode(token, secret, algorithms=["HS512"])RS256 uses a private key to sign and a public key to verify. Use it when a separate service issues tokens and many services verify them — they need the public key only, so a compromised verifier cannot mint tokens.
Secrets
Section titled “Secrets”The secret is the whole security boundary. Everything in Crypto about key storage applies:
- At least 32 bytes of entropy. PyJWT warns on shorter keys, and it is right to. Generate with
secrets.token_urlsafe(32). - From the environment, never committed.
- No fallback default. Crash at boot if it is missing.
- Different secrets per environment. A leaked staging secret must not mint production tokens.
Rotating a JWT secret invalidates every outstanding token at once, logging out every user. To rotate without that, add a kid (key ID) header, keep both keys during a transition window, pick the verification key by kid, and drop the old key once the longest-lived token has expired.
What not to do
Section titled “What not to do”Do not put secrets in the payload. It is readable by anyone holding the token.
Do not authorize on get_unverified_claims. The attacker writes those claims.
Do not omit algorithms on decode. That is the algorithm-confusion hole.
Do not use encode for auth tokens. It sets no expiry. Use create_access_token.
Do not issue long-lived access tokens. Every minute of lifetime is a minute you cannot revoke.
Do not store tokens in localStorage if you can avoid it. Any XSS reads it. An HttpOnly cookie is not readable from JavaScript — see Sessions and CSRF, because cookies bring their own trade-off.
Do not reuse the JWT secret for anything else. Not for session signing, not for CSRF tokens, not for encryption.
Do not skip the database check on refresh. Stateless refresh means no revocation at all.
Do not use a large leeway. It extends the life of every expired token.
Performance
Section titled “Performance”Signing and verifying HS256 is symmetric HMAC — microseconds, on the order of a SHA-256 of the payload. You can verify tens of thousands of tokens per second per core, which is why stateless auth is attractive.
RS256 is asymmetric and materially slower, particularly signing. Verification is cheaper than signing, which suits the many-verifiers topology it is meant for.
Token size matters more than CPU. Every claim you add is sent on every request by every client. A 2 KB token on a 100-request page load is 200 KB of pure overhead. Keep the payload to the identifiers you actually need, and look the rest up server-side.
Function reference
Section titled “Function reference”| Function | Signature | Returns |
|---|---|---|
encode | (payload, secret, algorithm="HS256", headers=None) | Token str, no auto-expiry |
decode | (token, secret, algorithms=None, options=None, audience=None, issuer=None, leeway=0) | Claims dict; raises on failure |
sign | (payload, secret, algorithm="HS256", headers=None) | Token as bytes |
verify | (token, secret, algorithms=None) | bool |
create_access_token | (data, secret, expires_delta=None, algorithm="HS256", issuer=None) | Token with exp and iat |
create_refresh_token | (data, secret, expires_delta=None, algorithm="HS256") | Token with a long default expiry |
get_unverified_header | (token) | Header dict. Unverified. |
get_unverified_claims | (token) | Claims dict. Unverified. |
decode_without_verification | (token) | Claims dict. Unverified. |
validate_claims | (payload, audience=None, issuer=None, leeway=0) | bool |
TokenError | Exception | Base class |
ExpiredTokenError | Exception | Valid signature, past expiry |
InvalidTokenError_ | Exception | Bad signature or malformed |
Related
Section titled “Related”- JWT Authentication — the full auth layer built on this module
- Crypto — signing and encryption primitives
- Hashing — password storage for the login step
- Protecting Routes — applying token auth to handlers
- Sessions — the stateful alternative, with revocation built in