Skip to content

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.

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:

    }
    return result.data;
}

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