Authentication¶
The platform uses cookie-based JWT authentication with CSRF protection via the double-submit pattern. This approach keeps tokens secure (httpOnly cookies) while enabling CSRF protection for state-changing requests.
Architecture¶
sequenceDiagram
participant Browser
participant Frontend
participant Backend
participant MongoDB
Browser->>Frontend: Login form submit
Frontend->>Backend: POST /auth/login
Backend->>MongoDB: Verify credentials
MongoDB-->>Backend: User record
Backend->>Backend: Generate JWT + CSRF token
Backend-->>Frontend: Set-Cookie: access_token (httpOnly)
Backend-->>Frontend: Set-Cookie: csrf_token
Frontend->>Frontend: Store CSRF in memory
Note over Browser,Backend: Subsequent requests
Browser->>Frontend: Click action
Frontend->>Backend: POST /api/... + X-CSRF-Token header
Backend->>Backend: Validate JWT from cookie
Backend->>Backend: Validate CSRF header == cookie
Backend-->>Frontend: Response
Token Flow¶
Login creates two cookies:
| Cookie | Properties | Purpose |
|---|---|---|
access_token |
httpOnly, secure, samesite=strict | JWT for authentication |
csrf_token |
secure, samesite=strict (readable) | CSRF double-submit verification |
The access_token cookie is httpOnly, so JavaScript cannot read it—this prevents XSS attacks from stealing the token.
The csrf_token cookie is readable by JavaScript so the frontend can include it in request headers.
Backend Implementation¶
Password Hashing¶
Passwords are hashed using bcrypt via passlib:
bcrypt__rounds=self.settings.BCRYPT_ROUNDS,
)
def verify_password(self, plain_password: str, hashed_password: str) -> bool:
return self.pwd_context.verify(plain_password, hashed_password) # type: ignore
def get_password_hash(self, password: str) -> str:
return self.pwd_context.hash(password) # type: ignore
def create_access_token(self, data: dict[str, Any], expires_delta: timedelta) -> str:
JWT Creation¶
JWTs are signed with HS256 using a secret key from settings:
expire = datetime.now(timezone.utc) + expires_delta
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, self.settings.SECRET_KEY, algorithm=self.settings.ALGORITHM)
return encoded_jwt
def decode_token(self, token: str) -> str:
The token payload contains the username in the sub claim and an expiration time. Token lifetime is configured via
ACCESS_TOKEN_EXPIRE_MINUTES (default: 24 hours / 1440 minutes).
CSRF Validation¶
The double-submit pattern requires the CSRF token to be sent in both a cookie and a header. The
validate_csrf_token dependency
validates this for all authenticated POST/PUT/DELETE requests:
if not header_token or not cookie_token:
return False
# Constant-time comparison to prevent timing attacks
return hmac.compare_digest(header_token, cookie_token)
# Paths exempt from CSRF validation (auth handles its own security)
CSRF_EXEMPT_PATHS: frozenset[str] = frozenset({
"/api/v1/auth/login",
"/api/v1/auth/register",
})
def validate_csrf_from_request(self, request: Request) -> str:
"""Validate CSRF token from HTTP request using double-submit cookie pattern.
Returns:
"skip" if validation was skipped (safe method, exempt path, or unauthenticated)
The validated token string if validation passed
Raises:
CSRFValidationError: If token is missing or invalid
"""
# Skip CSRF validation for safe methods
if request.method in ("GET", "HEAD", "OPTIONS"):
return "skip"
# Skip CSRF validation for auth endpoints
if request.url.path in self.CSRF_EXEMPT_PATHS:
return "skip"
# Skip CSRF validation for non-API endpoints (health, metrics, etc.)
if not request.url.path.startswith("/api/"):
Safe methods (GET, HEAD, OPTIONS) and auth endpoints (login, register, logout) skip CSRF validation.
Cookie Configuration¶
Login sets cookies with security best practices:
)
locked = await lockout_service.record_failed_attempt(form_data.username)
if locked:
raise HTTPException(
status_code=423,
detail="Account locked due to too many failed attempts",
)
raise HTTPException(
status_code=401,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Bearer"},
)
await lockout_service.clear_attempts(form_data.username)
effective = await runtime_settings.get_effective_settings()
session_timeout = effective.session_timeout_minutes
logger.info(
| Setting | Value | Purpose |
|---|---|---|
httponly |
true | Prevents JavaScript access (XSS protection) |
secure |
true | HTTPS only |
samesite |
strict | Prevents CSRF via cross-site requests |
path |
/ | Cookie sent for all paths |
Frontend Implementation¶
Auth Store¶
The frontend maintains authentication state in a Svelte store with sessionStorage persistence:
The store caches verification results for 30 seconds to reduce server load:
CSRF Injection¶
The API interceptor automatically adds the CSRF token header to all non-GET requests:
Session Handling¶
On 401 responses, the interceptor clears auth state and redirects to login, preserving the original URL for post-login redirect:
return true;
}
if (status === 401) {
handle401(isAuthEndpoint);
return true;
}
const mapped = STATUS_MESSAGES[status];
if (mapped) {
toast[mapped.type](mapped.message);
return true;
}
if (status === 422 && typeof error === 'object' && error !== null) {
const detail = (error as Record<string, unknown>).detail;
if (Array.isArray(detail) && detail.length > 0) {
toast.error(`Validation error: ${getErrorMessage(error)}`);
Login Lockout¶
After repeated failed login attempts, the account is temporarily locked to prevent brute-force attacks. The backend tracks attempts per username in Redis with a sliding-window TTL — each failed attempt resets the lockout timer.
When the attempt threshold is reached, subsequent login attempts return HTTP 423 (Locked) with the detail
"Account locked due to too many failed attempts". The lockout applies identically whether the username exists or not,
preventing username enumeration.
The lockout parameters are configured via SystemSettings:
| Setting | Default | Description |
|---|---|---|
max_login_attempts |
5 | Failed attempts before lockout |
lockout_duration_minutes |
15 | How long the lockout lasts (minutes) |
The frontend displays a warning toast for 423 responses via the API interceptor.
Registration Errors¶
Registration distinguishes between conflict types:
| Condition | HTTP Status | Detail |
|---|---|---|
| Username taken | 409 | Username already registered |
| Email taken | 409 | User already exists |
| Password too short | 400 | Password must be at least {min_len} characters |
The minimum password length is enforced at both the schema level (min_length=8 on UserCreate) and at runtime
from SystemSettings.password_min_length (default: 8, configurable by admins).
Endpoints¶
Offline-First Behavior¶
The frontend uses an offline-first approach for auth verification. On network failure, it returns the cached auth state rather than immediately logging out. This provides better UX during transient network issues but means server-revoked tokens may remain "valid" locally for up to 30 seconds.
Security-critical operations should use verifyAuth(forceRefresh=true) to bypass the cache.
Key Files¶
| File | Purpose |
|---|---|
core/security.py |
JWT, password, CSRF utilities |
services/auth_service.py |
Auth service layer |
api/routes/auth.py |
Auth endpoints |
services/login_lockout.py |
Redis-backed login lockout |
stores/auth.ts |
Frontend auth state |
api-interceptors.ts |
CSRF injection, error handling |