JWT Access Token - jbrucker/home-log GitHub Wiki
The backend service uses a JWT access token for protected endpoints.
The payload of the JWT contains (as of this writing) these fields:
-
user_idid of the authenticated user (Note: this field is usually namedsub) -
expan integer timestamp containing a timezone-aware datetime value when token expires.
The expiration key must be exp or else the jose JWT library will not check for expiration.
The code in app/utils/auth.py uses 3 settings (app.core.config.settings).
Settings reads the values from env vars.
| Settings | Env Var | Explanation |
|---|---|---|
jwt_algorithm |
JWT_ALGORITHM |
Hashing algorithm to use. "HS256", "ES256", or "RS256". |
secret_key |
SECRET_KEY |
256-bit secret key. For HS256 use a 32-byte random number. |
access_token_expire_minutes |
same in uppercase | Access token lifetime, in minutes. |
Let: expire_minutes be the lifetime of the token, in minutes.
from datetime import datetime, timezone
# Set expiration time. Use UTC time
expire = datetime.now(timezone.utc) + timedelta(minutes=expire_minutes)
# Must be int value, in seconds. Use int(timestamp) to remove fractional seconds.
expire_timestamp = int(expire.timestamp())
# Payload with expiration
payload = {
"sub": "user_id, username, or email", # we don't record this.
"user_id": user_id,
"exp": expire_timestamp,
# ... other claims ...
}
token = jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALGORITHM)To decode:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTClaimsError:
# one of the claims in the token is invalid (see docs for available claims)
except ExpiredSignatureError:
# the `exp` value has already expired
except JWTError:
# the signature is invalid
To get a readable datetime from the payload["exp"] value:
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
expiry =payload["exp"]
assert isinstance(expiry, int)
# timestamp was recorded in UTC time
expiry_datetime = datetime.fromtimestamp(expiry, tz=timezone.utc)
print("Expires: ", expiry_datetime, "UTC")
# Get the local date/time
local_tz = datetime.now().aszoneinfo().tzinfo
local_time = expiry_datetime.astimezone(tz=local_tz)
print("Local time:", local_time)There are several other standard "claims" that can be put in the payload:
Here is a concise table of common JWT registered claims supported by the python-jose (jose.jwt) module and their meanings:
| Claim | Meaning |
|---|---|
aud |
Audience β identifies the recipients the token is intended for |
exp |
Expiration Time β token expiry as UNIX timestamp, must be int |
iss |
Issuer β identifies who issued the token |
sub |
Subject β identifies the subject of the token, e.g., user ID |
nbf |
Not Before β token is not valid before this time |
iat |
Issued At β time the token was issued |
jti |
JWT ID β unique identifier for the token |
jose.jwt.decode() automatically validates exp, nbf, and iat if present, and will raise exceptions ExpiredSignatureError or JWTClaimsError if any are invalid.
You can also include other standard claims or custom claims. Custom claims are application-specific and not interpreted by jose.
jose.jwt.decode() automatically validates exp, nbf, and iat if present, and will raise exceptions ExpiredSignatureError or JWTClaimsError.
To verify other claims, use:
from jose import jwt
# Example inputs
token = "your.jwt.token"
key = "your-secret-key"
algorithms = ["HS256"]
# Decode with verification of specific claims
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=[JWT_ALGORITHM], # can list multiple algorithms to try
audience="your-audience", # Verifies the 'aud' claim
issuer="your-issuer", # Verifies the 'iss' claim
options={ # configure claim verification
"verify_exp": True,
"verify_nbf": True,
"verify_iat": False,
"verify_aud": True,
"verify_iss": True,
}
)The HS256 hash requires a 32-byte random number as secret key. Use any of these
- Command line:
openssl rand --hex 32 - Python
os.urandom(32) - Python
secrets.token_hex(32)from Pythonsecretsmodule
Important: In your unit tests, create a new SECRET_KEY in code; don't use the value from your .env file.
Test output might expose the "real" secret key.
The app should return HTTP 401 Unauthorized with this header field:
WWW-Authenticate: Bearer error="invalid_token", error_description="Token has expired"
according to RFC 7235.
When accessing protected endpoints using the browser-based OpenAPI docs at http://localhost:8000/docs, I sometimes get 401 Unauthorized errors even after authenticating via the OpenAPI page (the padlock icon). But not every time -- sometimes authentication works.
In every case, the OpenAPI docs show that it is including the "Authorization" header in the request:
curl -X 'GET' 'http://localhost:8000/api/users?limit=100&offset=0' \
-H 'accept: application/json' \
-H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjozLCJleHAiOjE3NjQ0ODUzNDF9.FRLGdYYK9omhGapt4p1qDlOfi_90Oy_UVU8xZFwEkhU'Likely causes and how to diagnose/fix:
Swagger UI βAuthorizeβ may require you include the "Bearer" prefix. Verify: Authorization: Bearer <token>.
Diagnose: open browser DevTools β Network β inspect the request headers for Authorization.
Quick test: Send the request from a shell using curl with the same header:
curl -v -H "Authorization: Bearer <TOKEN>" http://localhost:8000/api/usersIf the token was created outside the running container (or env vars differ), the container may use a different SECRET_KEY or JWT_ALGORITHM and reject the token.
Diagnose: check env/settings inside container:
docker compose logs backend | more
docker compose exec backend env | grep -E 'SECRET|JWT|SECRET_KEY|JWT_ALG'Fix: align environment variables (secret & algorithm) used to create and verify tokens.
Note: This app uses decouple.config() from the python-decouple package to directly get values from .env. No environment variables.
Token exp may already be past (or container clock differs).
Diagnose:
# check container time
docker compose exec backend date -u
inspect token payload (use https://jwt.io or jwt library) to confirm exp.
Fix: issue a new token / synchronize clocks.
Note: The container is (apparently) using UTC time but my host is UTC+7. However, the times are different by 7 hours so after applying the timezone, the times agree.
If Swagger UI is served from a different origin, the browser preflight must allow the Authorization header; otherwise the header will be dropped and the server sees an unauthenticated request.
Diagnose: DevTools β Console or Network β look for preflight (OPTIONS) failure or missing Access-Control-Allow-Headers.
Fix: configure FastAPI CORSMiddleware to include "Authorization" in allowed headers.
The OpenAPI security scheme might be defined differently than the server expects (e.g., cookie vs Bearer header). Diagnose: check FastAPI security dependencies and OpenAPI securityScheme; ensure itβs HTTP bearer and endpoint dependency reads Authorization header.
Fix: make the security scheme and the auth dependency agree.
If your verify code expects a particular claim name or type (e.g., 'exp' present, integer) but token is missing/altered, decode will fail.
Diagnose: check server logs for JWT decode exceptions (docker compose logs backend | grep -A 3 -i JWT).
Fix: ensure create_access_token sets standard 'exp' and verification uses same library/algorithms.
- Inspect the actual request headers in the browser (Authorization present? value correct?).
- Reproduce with curl/HTTP to rule out Swagger UI/browser CORS.
- Check backend logs for JWT decode/expiry errors.
- Verify container env vars: SECRET_KEY, JWT_ALGORITHM, token expiry settings.
- Inspect token payload (jwt.io or jwt.decode locally) to confirm exp, alg, and claims.
https://jwt.io has a concise introduction to JWT and interactive JWT decoder.
Python 'jose' project on pypi provides JWE (Web Encryption), JWK (Web Key), JWS (Web Signatures), and JWT.
Python 'jose' on ReadTheDocs is outdated (2015) and incomplete.