Skip to content

Authentication & Security

Mirabelle uses JWT-based stateless authentication with support for local passwords, Microsoft OAuth, and Google OAuth. All tokens are HS256-signed JWTs. Token storage on the client uses localStorage.


Token Flow

sequenceDiagram
    participant Browser
    participant Vite
    participant API
    participant MasterDB
    participant TenantDB

    Browser->>Vite: POST /api/auth/login (email, password, account_id)
    Vite->>API: proxy to http://localhost:8002/api/auth/login
    API->>TenantDB: SELECT user WHERE email=?
    TenantDB->>API: user row
    API->>API: bcrypt.verify(password, hashed_password)
    API->>MasterDB: get JWT secret from master.parameters
    API->>API: jwt.encode(payload, secret, HS256)
    API->>Browser: { access_token, expires_in, user }
    Browser->>Browser: localStorage.setItem('forecastai_token', token)

    Browser->>Vite: GET /api/forecasts (Authorization: Bearer <token>)
    Vite->>API: proxy with Authorization header
    API->>API: JWTAuthMiddleware.dispatch()
    API->>API: jwt.decode(token, secret, HS256)
    API->>TenantDB: SELECT 1 FROM revoked_tokens WHERE jti=?
    API->>Browser: 200 OK with forecast data

JWT Structure

Every token issued by Mirabelle contains the following claims:

{
  "jti": "550e8400-e29b-41d4-a716-446655440000",
  "sub": "user-uuid",
  "email": "alice@example.com",
  "role": "admin",
  "display_name": "Alice",
  "auth_provider": "local",
  "allowed_segments": [1, 2, 3],
  "can_run_process": true,
  "can_create_override": true,
  "allowed_segments_edit": [1],
  "is_superadmin": false,
  "account_id": "tenant-account-uuid",
  "account_name": "The Bicycle Company",
  "exp": 1744000000
}
Claim Description
jti JWT ID — unique identifier used for token revocation
sub User UUID (from scenario.users.id)
email User email
role admin, user, or superadmin
display_name Display name shown in the UI
auth_provider local, microsoft, or google
allowed_segments Segment IDs the user can view (empty = all for admin)
can_run_process Permission to trigger pipeline runs
can_create_override Permission to apply forecast adjustments
allowed_segments_edit Segment IDs the user can edit
is_superadmin True for SuperAdmin accounts
account_id Tenant account UUID — used to route to the correct DB
account_name Tenant display name — shown in the UI toolbar
exp Expiry timestamp (Unix epoch seconds)

Token Expiry

Default expiry is 480 minutes (8 hours). This is configurable in master.parameters:

UPDATE master.parameters
   SET parameters_set = jsonb_set(parameters_set, '{token_expiry_minutes}', '480')
 WHERE parameter_type = 'auth';

Algorithm and Secret

  • Algorithm: HS256 (HMAC-SHA256)
  • Secret storage: master.parameters (type=auth, name=Default) under key jwt_secret
  • Secret loading: Called in _get_jwt_secret() in main.py which first tries db.master_db.get_master_jwt_secret() then falls back to the tenant DB auth parameter set

Change the JWT secret before production

The default secret is CHANGE-ME-IN-PRODUCTION. Any token signed with this secret can be decoded by anyone. Generate a cryptographically random secret of at least 32 bytes:

import secrets
print(secrets.token_hex(32))
Then update master.parameters:
UPDATE master.parameters
   SET parameters_set = jsonb_set(parameters_set, '{jwt_secret}', '"your-random-secret-here"')
 WHERE parameter_type = 'auth';


Authentication Providers

Local (Username + Password)

Users with auth_provider = 'local' use bcrypt-hashed passwords stored in scenario.users.hashed_password.

Login endpoint: POST /api/auth/login

// Request
{
  "email": "user@example.com",
  "password": "their-password",
  "account_id": "tenant-account-uuid"
}

// Response
{
  "access_token": "<jwt>",
  "token_type": "bearer",
  "expires_in": 28800,
  "user": { "id": "...", "email": "...", "role": "admin", ... }
}

Password hashing uses passlib.context.CryptContext(schemes=["bcrypt"], deprecated="auto"). The bcrypt work factor follows passlib's default (12 rounds).

Microsoft OAuth

Flow: Client obtains a Microsoft access token from the Microsoft Identity Platform (MSAL) and sends it to:

POST /api/auth/microsoft

{
  "access_token": "<microsoft-access-token>",
  "account_id": "tenant-account-uuid"
}

The API uses httpx to call https://graph.microsoft.com/v1.0/me to validate the token and retrieve the user's email. It then looks up or creates the user in scenario.users with auth_provider = 'microsoft' and issues a Mirabelle JWT.

Google OAuth

Flow: Client uses Google Identity Services to obtain a Google ID token (a JWT) and sends it to:

POST /api/auth/google

{
  "credential": "<google-id-token>",
  "account_id": "tenant-account-uuid"
}

The API decodes the Google ID token (verifying the signature against Google's public keys via httpx) to extract the user's email and display name. It then looks up or creates the user in scenario.users with auth_provider = 'google' and issues a Mirabelle JWT.


Token Storage (Frontend)

The React frontend stores the JWT in localStorage under two keys (for historical compatibility): - forecastai_token — the current active token - mirabelle_token — alias used by some components

All API calls use the shared Axios instance in files/frontend/src/utils/api.js, which automatically injects the Authorization: Bearer <token> header from localStorage. A 401 response triggers an automatic redirect to /login.

localStorage security

localStorage is accessible to all JavaScript on the page, making it vulnerable to XSS attacks. Mirabelle does not currently use HttpOnly cookies. Ensure your deployment has a strict Content Security Policy and avoids loading third-party JavaScript from untrusted sources.


Server-Sent Events (SSE) Authentication

Browsers cannot set custom headers on EventSource connections. For live log streaming (/api/process/stream/<run_id>), the token is passed as a query parameter:

const source = new EventSource(`/api/process/stream/${runId}?token=${token}`);

The middleware handles this fallback:

auth_header = request.headers.get("Authorization", "")
token = None
if auth_header.startswith("Bearer "):
    token = auth_header[7:]
else:
    token = request.query_params.get("token")  # SSE fallback

Token in URL

Query-parameter tokens appear in server access logs and browser history. Mirabelle limits this to SSE endpoints where no alternative exists. Ensure access logs are appropriately secured in production.


Logout and Token Revocation

POST /api/auth/logout (authenticated) inserts the token's jti into scenario.revoked_tokens and returns 200. The frontend clears localStorage.

# Conceptual logout handler
jti = request.state.jti
with conn.cursor() as cur:
    cur.execute(
        f"INSERT INTO {schema}.revoked_tokens (jti) VALUES (%s) ON CONFLICT DO NOTHING",
        (jti,)
    )
conn.commit()

The revocation check runs on every subsequent request in JWTAuthMiddleware before the request reaches any handler.


Password Change

POST /api/auth/change-password (authenticated, local accounts only):

{
  "current_password": "old-password",
  "new_password": "new-password"
}

The handler verifies current_password against the stored bcrypt hash, then hashes and stores new_password. Returns 400 if the current password is incorrect or if the account uses OAuth.

SuperAdmin password divergence

The change-password endpoint only updates the tenant DB (scenario.sys_users). SuperAdmins also have a row in master.superadmins with its own hashed_password. When a superAdmin changes their password via the UI, the master DB hash is not updated, causing the superadmin login to silently fall through to the tenant user path on next login (resulting in role: 'admin' instead of role: 'superadmin').

Fix: After a superAdmin changes their password, also update master.superadmins:

UPDATE master.superadmins
SET hashed_password = (SELECT hashed_password FROM scenario.sys_users WHERE email = 'user@example.com')
WHERE email = 'user@example.com';

The API logs a warning when this divergence is detected during login (since 2026-06-14). See User Management → SuperAdmin Password Divergence for full details.


User Management Endpoints

All user management requires admin or superadmin role.

Method Path Description
GET /api/auth/users List all users in the current tenant
POST /api/auth/users Create a new user
PUT /api/auth/users/{id} Update user (role, segments, permissions)
DELETE /api/auth/users/{id} Deactivate (soft delete) a user
GET /api/auth/me Get current user profile
POST /api/auth/logout Revoke current token
POST /api/auth/change-password Change password (local accounts)

Dependency Injection in Endpoints

Every protected endpoint uses the get_current_user dependency from files/api/auth.py:

from api.auth import get_current_user

@app.get("/api/forecasts")
async def get_forecasts(
    pipeline_id: int,
    user: dict = Depends(get_current_user),
):
    # user is already validated — 401 if missing/expired token
    # user = {"id": ..., "email": ..., "role": ..., "allowed_segments": [...], ...}
    if not _user_has_segment_access(user, requested_segment_id):
        raise HTTPException(status_code=403, detail="Access denied")
    ...

get_current_user reads request.state.user which was set by JWTAuthMiddleware. It raises HTTP 401 if the user is not authenticated.

For admin-only endpoints:

@app.post("/api/admin/something")
async def admin_action(user: dict = Depends(get_current_user)):
    if user.get("role") not in ("admin", "superadmin"):
        raise HTTPException(status_code=403, detail="Admin access required")
    ...

Public Paths

The following paths bypass JWT validation entirely:

Exact paths (from PUBLIC_PATHS): - / — API root - /docs — Swagger UI - /openapi.json — OpenAPI schema - /redoc — ReDoc documentation

Path prefixes (from PUBLIC_PREFIXES): - /api/auth/login — login endpoint - /api/auth/microsoft — Microsoft OAuth - /api/auth/google — Google OAuth - /api/auth/probe — connectivity probe (used by the frontend to check API availability)

All other paths require a valid, non-expired, non-revoked Bearer token.


Security Considerations

Concern Current State Recommendation
JWT secret Configurable, defaults to weak value Change before any network exposure
Token storage localStorage (XSS-vulnerable) Consider HttpOnly cookies for higher-security deployments
Token lifetime 480 min (8 hours) Reduce to 60 min + implement refresh tokens for production
CORS Localhost origins only Update to production domain before exposing on network
HTTPS Not enforced (HTTP) Put Nginx with TLS termination in front for any non-localhost use
bcrypt rounds 12 (passlib default) Acceptable; increase to 14 for very high-security environments
Token in URL SSE only Ensure access logs are secured
Revocation table Grows indefinitely Add a periodic cleanup job to remove expired JTIs