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_id id of the authenticated user (Note: this field is usually named sub)
  • exp an 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.

Compute Timestamp for Expiration Date/time

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)

JWT Claims

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.

Verify Claims

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,
    }
)

How to Get a Random Number for HS256 Secret Key

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 Python secrets module

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.

What to return if token expired?

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.

Debugging Authentication Issues

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:

1. Token not being sent (or malformed)

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/users

2. App and token issuer using different secrets / algorithms

If 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.

3. Token expired or clock skew

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.

4. CORS / preflight blocking Authorization header

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.

5. Security scheme mismatch (FastAPI/OpenAPI)

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.

6. JWT library/claim handling differences

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.

Debug checklist

  • 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.

References

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.

⚠️ **GitHub.com Fallback** ⚠️