# Research: JWT Bearer Token Authentication **Feature**: `004-jwt-bearer-auth` | **Date**: 2026-05-03 --- ## Decision 1 — JWT library **Decision**: `PyJWT>=2.8` **Rationale**: The project needs only HS256 signing with a single symmetric secret key — the simplest possible JWT profile. `PyJWT` is the de-facto standard Python JWT library for this use case: no additional crypto dependencies, actively maintained, wide community adoption. `python-jose` was the alternative; it has broader JOSE/JWE support but has had maintenance gaps and brings extra dependencies that we do not need. For Phase 3 (OIDC), token issuance is replaced by the external identity provider. The `JWTAuthProvider` will be replaced by an OIDC-aware provider; the library choice for Phase 2 does not constrain Phase 3. **Alternatives considered**: `python-jose[cryptography]` — wider JOSE support but heavier dependency tree and slower maintenance cadence. Rejected. --- ## Decision 2 — Password storage for the single owner account **Decision**: Store plaintext `OWNER_USERNAME` and `OWNER_PASSWORD` in environment variables; compare at login time using `secrets.compare_digest` to prevent timing attacks. **Rationale**: This is a single-user self-hosted application accessed over a trusted local network. The password is already known to the person deploying the application (they set it). Bcrypt pre-hashing would require operators to run a separate tool to generate the hash before setting the env var, adding friction with no meaningful security benefit for this threat model. In Phase 3 the owner credentials are replaced by an external OIDC provider entirely, so this is a temporary mechanism with limited lifetime. `secrets.compare_digest` is used instead of `==` to prevent any theoretical timing oracle. **Alternatives considered**: `passlib[bcrypt]` with a pre-hashed password env var — more "correct" in absolute terms but adds operator complexity for a single-user local app. Rejected. --- ## Decision 3 — JWT algorithm and claims **Decision**: HS256 (HMAC-SHA256) with claims: `sub` (fixed string `"owner"`), `iat` (issued-at epoch seconds), `exp` (expiry epoch seconds). **Rationale**: HS256 is symmetric — a single `JWT_SECRET_KEY` env var is used for both signing and verification. This is appropriate for a single-server deployment where only the API ever validates tokens. RS256 (asymmetric) would be needed if a second service needed to verify tokens independently; that is not the case here and would be added complexity. The `sub` claim carries the owner identifier. `exp` enables configurable expiry. `iat` is included for auditability. **Alternatives considered**: RS256 — appropriate when multiple services verify tokens. Overkill for this single-server deployment. Rejected. --- ## Decision 4 — Login endpoint request format **Decision**: JSON body `{"username": "...", "password": "..."}` at `POST /api/v1/auth/token`. Response: `{"access_token": "...", "token_type": "bearer", "expires_in": }`. **Rationale**: The OAuth2 `application/x-www-form-urlencoded` format (`grant_type=password, username, password`) is the spec-compliant form for the Resource Owner Password Credentials grant. However, we are not building a full OAuth2 authorization server — this is a simplified login endpoint for a single-user SPA. A JSON body is simpler to consume from Angular's `HttpClient`, avoids `URLSearchParams` boilerplate, and does not mislead consumers into thinking this is a full OAuth2 endpoint. The response shape (`access_token`, `token_type`) follows the OAuth2 bearer token response convention because Phase 3 (OIDC) will also produce tokens in this shape — the Angular `AuthService` does not need to change its token-parsing logic. **Alternatives considered**: OAuth2 password grant form format — interoperable but unnecessarily strict for this use case. Rejected. --- ## Decision 5 — `AuthProvider` interface evolution **Decision**: Evolve `get_identity()` to accept a single optional string argument: `async def get_identity(self, authorization: str | None) -> Identity`. `NoOpAuthProvider` ignores the argument and returns the anonymous identity as before. `JWTAuthProvider` parses `"Bearer "`, validates the JWT, and returns a non-anonymous `Identity`, or raises a `401` via `HTTPException` if the token is invalid or expired. A new `require_auth` dependency in `dependencies.py` calls `auth.get_identity(authorization_header)` and raises `401` if the returned identity is anonymous. Protected routes inject `Depends(require_auth)`. Public routes continue to bypass auth entirely — they neither inject auth nor call `get_identity`. **Rationale**: Minimal interface change that preserves backward compatibility (`NoOpAuthProvider` continues to work unchanged) while allowing the JWT provider to access the request header cleanly through FastAPI's `Header` dependency. An alternative would be injecting `Request` directly into the provider, but that couples the provider to the ASGI framework; a string header value keeps the provider framework-agnostic. **Alternatives considered**: Pass `Request` to `get_identity()` — couples the provider to FastAPI/ASGI. Rejected. Create a separate `validate_token(token)` method — more interface surface, no clear benefit over the chosen approach. Rejected. --- ## Decision 6 — Token storage in the browser **Decision**: `sessionStorage` — tokens are discarded when the browser tab is closed. **Rationale**: `localStorage` persists across browser sessions and is accessible to any JavaScript on the page, making it a wider XSS target. `sessionStorage` is scoped to the tab and cleared on close, giving better security for a shared or semi-public machine. For a personal app used by the owner on their own machine, the loss of persistence across browser restarts is a minor inconvenience that is well worth the security improvement. `HttpOnly` cookies would be more secure still but require CSRF protection and server-side session management, which conflicts with the stateless JWT design. **Alternatives considered**: `localStorage` — persistent but wider XSS exposure. Rejected. `HttpOnly` cookie — strongest XSS protection but requires CSRF mitigation and session server state. Rejected. --- ## Decision 7 — Angular interceptor API **Decision**: Functional HTTP interceptor registered via `provideHttpClient(withInterceptors([authInterceptor]))` in `app.config.ts`. The interceptor reads the token from `AuthService.getToken()` and adds `Authorization: Bearer ` to every outbound request if a token is present. **Rationale**: Angular 17+ prefers functional interceptors over class-based ones (`APP_INITIALIZER` / `HTTP_INTERCEPTORS` token). The functional pattern integrates with standalone components and is the current idiomatic approach. The interceptor attaches the token to all requests unconditionally (not just protected endpoints) — the API ignores the header on public endpoints, so this is safe and avoids the complexity of URL matching in the interceptor. **Alternatives considered**: Class-based `HttpInterceptor` — legacy pattern, not aligned with Angular 17+ standalone idiom. Rejected. --- ## Decision 8 — No database migration required **Decision**: JWTs are stateless — no token storage or blocklist is introduced. **Rationale**: The tokens carry their own expiry; the API validates them by signature and expiry on each request. A token blocklist (for logout invalidation) would require a database table and lookup on every protected request, adding complexity disproportionate to the threat model of a single-user local application. On logout, the Angular client discards the token from `sessionStorage`; the token technically remains valid until its `exp`, but since there is no other client that holds it, this is acceptable. **Alternatives considered**: Token blocklist table — true server-side logout. Out of scope for Phase 2; noted as a potential hardening step. --- ## Summary of new dependencies | Package | Where | Purpose | |---------|-------|---------| | `PyJWT>=2.8` | `api/pyproject.toml` | JWT signing and verification | No new UI dependencies — Angular's `HttpClient` and `Router` cover all interceptor, guard, and HTTP needs.