Skip to Content

Auth Flow

AEGIS uses a two-step authentication flow: email/password credentials are exchanged for JWT tokens, and the JWT is validated by the API gateway on every request.

Flow Diagram

Browser Frontend API Gateway Auth Service | :3000 :8000 :8009 | | | | | 1. Enter email+password | | | | ----------------------> | | | | | | | | 2. POST /api/v1/auth/token | | | | -----------------> | -----------------> | | | | {email,password} | | | | | | | | 3. Verify bcrypt | | | | Lookup user | | | | Sign JWT | | | | <----------------- | | | | Set-Cookie: | | | <----------------- | aegis_token (JWT) | | | | body: identity | | 4. Browser stores the | | | | httpOnly cookie | | | | <---------------------- | | | | | | | | 5. API request (cookie | | | | rides same-origin) | | | | ----------------------> | Cookie: | | | | aegis_token={jwt} | | | | -----------------> | | | | | | | | 6. Validate JWT locally | | | (HS256 fast-reject) | | | | POST /auth/resolve | | | | -----------------> | | | | <----------------- | | | | roles+permissions | | | | (DB-fresh, 5s | | | | cache) | | | 7. Stamp X-User-Id / | | | X-User-Email / X-Roles /| | | X-Tenant-Id / | | | X-Permissions | | | | -------> Backend | | | | 8. Service verifies the | | | stamped headers | | | (aegis_shared.auth, | | | R42d) |

Step-by-Step

1. Login (Email/Password to JWT)

The frontend login page collects an email and password and exchanges them for a JWT:

Request:

POST /api/v1/auth/token Content-Type: application/json {"email": "admin@aegis.local", "password": "aegis-dev-admin"}

Response (R42b — the token rides ONLY in the Set-Cookie header, never the body):

{ "expires_in": 86400, "user_id": "52c8f756-...", "roles": ["admin", "operator", "reviewer"], "email": "admin@aegis.local", "display_name": "Jane Operator" }
Set-Cookie: aegis_token=eyJhbGciOiJIUzI1NiIs...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400

(HttpOnly/Secure are env-gated via AEGIS_COOKIE_HTTPONLY / AEGIS_COOKIE_SECURE, both ON in deployed environments since the R42b cutover.) Programmatic clients — the smoke/golden/integration harnesses — capture the cookie value and may then send it as Authorization: Bearer or as the cookie; the gateway accepts both.

display_name may be null for accounts created without one; the frontend header falls back to email, then user_id, for its human-readable label.

The auth service looks up the user by LOWER(email) in the users table, verifies the password against the stored bcrypt hash, then generates a JWT with:

JWT ClaimValue
user_idUser identifier
emailUser email address
display_nameHuman-readable name (may be null)
rolesArray of role keys, read from user_roles at mint time
tenant_idUser’s tenant (forwarded as X-Tenant-Id)
tvToken version (R42a) — revocation seam; see below
issaegis-auth
iatCurrent UTC timestamp
expCurrent time + 24 hours

The JWT is signed using HS256 with the JWT_SECRET environment variable. Since R42b the session is cookie-only: the login response server-sets the aegis_token cookie and the body carries identity fields for immediate UI hydration — no access_token.

Fresh-at-perimeter resolution (R42a)

The JWT carries identity only; authorization state is re-resolved from the database on demand via POST /auth/resolve — a single resolve_user() codepath that also backs /auth/forward-auth (Caddy) and the auth service’s own admin endpoints. It verifies the signature, then checks users.is_active and users.token_version against the tv claim (missing = 0) and flattens user_rolesrole_permissions into a permission union, behind a ~5s per-user cache. Consequences:

  • Role changes and deactivation bind on live traffic within ~5 seconds — no re-login needed
  • POST /auth/logout-all, admin deactivation, and password resets bump token_version, revoking every outstanding token ({"valid": false, "reason": "revoked"})
  • Caddy /svc/* traffic gets the same freshness for free, plus a new X-Permissions header alongside X-User-Id / X-Roles / X-Tenant-Id

Since R42b the Go gateway consumes /auth/resolve on every authenticated request (see below) — stale-claims authorization is gone at both perimeters. /api/v1/auth/resolve itself is NotFound’d at the gateway (internal-only).

Identity normalization (R42e)

User identity is a uuid end-to-end: R42e retyped the platform’s VARCHAR/TEXT identity columns to uuid and added database-level FKs to users(id) on ~17 core columns (core migration 031 + agent-config migration 020). Supporting pieces:

  • Four non-loginable system userssystem@aegis.local (nil UUID), legacy@aegis.local, eval-runner@aegis.local, demo-seed@aegis.local. Their empty password hash fails closed at login; is_active=true so they satisfy FKs and attribution joins.
  • identity_map resolves historical identity strings to real users at read time — the append-only, hash-chained audit columns (audit_logs.actor_id, prompt_audit_log.actor_id) stay TEXT permanently and are never rewritten.
  • The pre-R42 admin-author sentinel UUID is retired; agent-config remapped skill ownership to the real admin.

See Identity Normalization on the schema page for the column list.

2. Token Storage

The auth service sets the aegis_token cookie (httpOnly — the frontend never reads or writes it; there is no document.cookie handling anywhere in the client). All frontend API calls are same-origin (/api/v1/* — Caddy on the box, a next.config.ts rewrite in dev), so the browser attaches the cookie automatically, including for EventSource/SSE requests that cannot set headers. The UI learns who is logged in from GET /auth/me, refreshes the session via POST /auth/refresh (on mount + every 30 minutes), and logs out via POST /auth/logout.

3. Request Authentication

The API gateway validates every incoming request through its middleware chain (reordered in R42b so the rate limiter sees the authenticated user):

CORS Middleware --> Auth Middleware --> Rate Limiter --> Route Handler

The Auth Middleware (internal/middleware/auth.go) performs the following:

  1. Skip list: Health checks (/health), token generation (/api/v1/auth/token), logout (/api/v1/auth/logout — so a client with an expired token can still clear its cookie), the public invite endpoints (/api/v1/auth/invites/*), and public entity type endpoints bypass auth
  2. Read the JWT: Check the Authorization header for Bearer {token}, falling back to the aegis_token cookie. Validate locally using JWT_SECRET with HS256 (fast reject)
  3. Resolve fresh authorization (internal/resolver): consult an in-memory cache keyed sha256(token)POST /auth/resolve on auth-service. Results ≤5s old are served from cache; older entries are re-fetched with a 500ms timeout; if auth-service is down, a stale result is served up to 60s old (stale-while-revalidate — an auth-service restart does not 401 the platform); beyond 60s the request fails closed with 401
  4. Reject: missing/invalid/revoked/inactive tokens return 401

On success, the middleware stamps X-User-Id, X-User-Email (R42d), X-Roles, X-Tenant-Id, and X-Permissions from the resolve result (not raw JWT claims) using Set, so client-supplied values cannot be spoofed, and adds user_id/roles to the request context for route guards. Caddy’s forward_auth stamps the same five headers onto /svc/* traffic via copy_headers.

4. Role-Based Access

Certain gateway routes enforce role requirements:

Route PatternRequired Role
/api/v1/admin/*admin
/api/v1/detection-rules*power_user or admin
All other authenticated routesAny valid user

The role check happens in the route handler, not in the auth middleware:

roles, _ := r.Context().Value(middleware.RolesKey).([]string) if !hasRole(roles, "admin") { http.Error(w, `{"error":"admin role required"}`, http.StatusForbidden) return }

5. Service-Side Enforcement (R42d)

Since R42d the perimeter is no longer the only authorization layer: every Python service verifies the perimeter-stamped identity headers itself, via the shared module shared/src/aegis_shared/auth.py. The trust argument: both perimeters (gateway AuthMiddleware, Caddy forward_auth) strip inbound copies of the identity headers on every path before stamping their own, so a non-empty X-User-Id that reaches a service through a perimeter is trustworthy by construction — while a direct service-port request without the headers is rejected on protected routes.

The module provides FastAPI dependencies, adopted per-router / per-route (never app-level middleware, so health checks, seed endpoints, and the render/resolve hot paths are excluded by construction):

DependencyBehavior
get_current_userExtracts CurrentUser (user_id, email, roles, permissions, tenant_id) from the stamped headers; missing X-User-Id401 in enforce mode
get_optional_userTenant required, user optional — for service-to-service hot paths (e.g. prompt render) that legitimately carry no end user
require_role("power_user", "admin")Coarse route-level gate; 403 if the user holds none of the roles
require_permission("hitl.approve")Fine-grained gate. Reads the DB-fresh X-Permissions header when stamped; falls back to the static role→permission table (a mirror of auth-service’s rbac.PERMISSION_MATRIX — a test asserts the two stay identical)
require_permission_if_user(...)For dual-surface routes reached both by end users and by header-less internal service callers (e.g. flaring’s POST /events, also written by KG’s detection engine): a present user must hold the permission; an identity-less request passes as an internal caller

Rollout switch AEGIS_AUTH_MODE (log | enforce, default log):

  • log — a missing identity or failed role/permission check emits one structured warning per request (aegis.auth missing_identity ... / aegis.auth denied ...) and lets the request through. Every service soaks in this mode first.
  • enforce — missing identity → 401; failed check → 403.

The flip to enforce is gated on a 24-hour zero-warning soak on the box; any warning during the soak is a straggler (script, skill, bookmark, monitor) to fix, not a reason to shorten the gate. Rollback is flipping back to log.

memory-service is deliberately excluded — its entire surface is service-to-service (documented in its module docstring).

Permission Matrix (R42 Appendix C)

The seeded role→permission matrix, signed off in docs/specs/R42-tenant-users-roles-spec.md Appendix C. The DB copy (role_permissions, stamped as X-Permissions) is authoritative; the table in aegis_shared.auth.ROLE_PERMISSIONS is the header-less fallback.

Permissionadminpower_userrevieweroperator
users.read / users.manage / roles.grantx
settings.managex
skills.approve / prompts.approvex
skills.author / prompts.authorxx
skills.proposexxx
config_types.manage / detection_rules.managexx
hitl.approve / filings.reviewxx
filings.submitxxx
eval.runxx
eval.readxxx
events.logxxx
conversations.use / dashboards.readxxxx

Notable fine-grained gates: hitl.approve on approval decide/escalate (approval-service), filings.submit/filings.review on checklist submit/reject (orchestration), config_types.manage on KG and flaring config-type writes, events.log on operational/flare event writes, skills.author/skills.propose/prompts.author on agent-config authoring writes, and eval.read/eval.run on the eval routes.

Rate Limiting

The gateway applies per-user rate limiting:

ParameterValue
Rate600 requests/minute (10 req/sec) — raised in R42b for dashboard mount bursts
Burst100 requests
Keyauthenticated user ID (the limiter runs after auth since R42b; behind Caddy every remote address is 127.0.0.1, so pre-auth IP keying would collapse all users onto one bucket)
ImplementationGo golang.org/x/time/rate token bucket

When the limit is exceeded, the gateway returns:

HTTP 429 Too Many Requests {"error": "rate limit exceeded -- 600 requests/minute"}

Development Authentication

For local development, the auth service seeds a bootstrap admin on startup (from BOOTSTRAP_ADMIN_EMAIL / BOOTSTRAP_ADMIN_PASSWORD, defaulting to the values below). Additional users are created via the Users & Roles admin page at /admin/users (invite links, R42d) or the create_user CLI fallback — there is no self-serve signup.

EmailPasswordRoles
admin@aegis.localaegis-dev-adminadmin, operator, reviewer

The default bootstrap credentials grant all three roles. In production, set strong BOOTSTRAP_ADMIN_* values and provision real accounts via the Users & Roles admin page (or the CLI fallback) with granular role assignments.

Auth Service Endpoints

MethodPathDescription
POST /auth/tokenExchange email + password for a sessionSet-Cookie aegis_token; body {expires_in, user_id, roles, email, display_name} (no token since R42b)
POST /auth/resolveJWT → DB-fresh identity, roles, permissionsInternal (R42a); enforces is_active + token_version, ~5s cache; NotFound’d at the gateway (R42b)
GET /auth/meCaller identity + permissionsSession endpoint (R42a)
POST /auth/refreshSliding refresh (>1h-old tokens re-minted)Session endpoint (R42a)
POST /auth/logout / logout-allExpire cookie / revoke all tokensSession endpoints (R42a)
POST/GET/PATCH /auth/admin/users*Admin user lifecycle + invites/resetR42a; users.manage / roles.grant gated; deactivate-only
GET /auth/invites/{token} / POST /auth/invites/redeemPublic invite validation/redemptionR42a
POST /auth/validateValidate a JWT token (claims-only, legacy)Used internally; returns {valid, user_id, roles}
* /auth/forward-authValidate the aegis_token cookie or Bearer headerUsed by Caddy; method-agnostic and DB-fresh since R42a. Always returns X-User-Id / X-Roles / X-Tenant-Id / X-Permissions response headers so Caddy copy_headers stamps identity onto /svc/* proxied requests
GET /healthHealth checkReturns {status: "ok", service: "auth-service"}

Security Notes

  • JWT tokens are signed with HS256 using the shared JWT_SECRET. Both the auth service and the gateway use the same secret
  • The JWT_SECRET default value (aegis-local-dev-secret-change-in-production) must be replaced for production deployments
  • JWT expiry is 24 hours, with a sliding refresh (POST /auth/refresh re-mints tokens older than 1h) and revocation via token_version (R42a)
  • Backend services never see or re-validate JWTs. Since R42d they verify the perimeter-stamped identity headers via aegis_shared.auth (mode-gated by AEGIS_AUTH_MODE) — a direct service-port request without the headers gets 401 on protected routes in enforce mode
  • The gateway strips and re-applies CORS headers, ensuring backends cannot override the CORS policy
Last updated on