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 Claim | Value |
|---|---|
user_id | User identifier |
email | User email address |
display_name | Human-readable name (may be null) |
roles | Array of role keys, read from user_roles at mint time |
tenant_id | User’s tenant (forwarded as X-Tenant-Id) |
tv | Token version (R42a) — revocation seam; see below |
iss | aegis-auth |
iat | Current UTC timestamp |
exp | Current 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_roles → role_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 bumptoken_version, revoking every outstanding token ({"valid": false, "reason": "revoked"})- Caddy
/svc/*traffic gets the same freshness for free, plus a newX-Permissionsheader alongsideX-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 users —
system@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=trueso they satisfy FKs and attribution joins. identity_mapresolves 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 HandlerThe Auth Middleware (internal/middleware/auth.go) performs the following:
- 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 - Read the JWT: Check the
Authorizationheader forBearer {token}, falling back to theaegis_tokencookie. Validate locally usingJWT_SECRETwith HS256 (fast reject) - Resolve fresh authorization (
internal/resolver): consult an in-memory cache keyedsha256(token)→POST /auth/resolveon 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 - 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 Pattern | Required Role |
|---|---|
/api/v1/admin/* | admin |
/api/v1/detection-rules* | power_user or admin |
| All other authenticated routes | Any 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):
| Dependency | Behavior |
|---|---|
get_current_user | Extracts CurrentUser (user_id, email, roles, permissions, tenant_id) from the stamped headers; missing X-User-Id → 401 in enforce mode |
get_optional_user | Tenant 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.
| Permission | admin | power_user | reviewer | operator |
|---|---|---|---|---|
users.read / users.manage / roles.grant | x | |||
settings.manage | x | |||
skills.approve / prompts.approve | x | |||
skills.author / prompts.author | x | x | ||
skills.propose | x | x | x | |
config_types.manage / detection_rules.manage | x | x | ||
hitl.approve / filings.review | x | x | ||
filings.submit | x | x | x | |
eval.run | x | x | ||
eval.read | x | x | x | |
events.log | x | x | x | |
conversations.use / dashboards.read | x | x | x | x |
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:
| Parameter | Value |
|---|---|
| Rate | 600 requests/minute (10 req/sec) — raised in R42b for dashboard mount bursts |
| Burst | 100 requests |
| Key | authenticated 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) |
| Implementation | Go 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.
| Password | Roles | |
|---|---|---|
admin@aegis.local | aegis-dev-admin | admin, 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
| Method | Path | Description |
|---|---|---|
POST /auth/token | Exchange email + password for a session | Set-Cookie aegis_token; body {expires_in, user_id, roles, email, display_name} (no token since R42b) |
POST /auth/resolve | JWT → DB-fresh identity, roles, permissions | Internal (R42a); enforces is_active + token_version, ~5s cache; NotFound’d at the gateway (R42b) |
GET /auth/me | Caller identity + permissions | Session endpoint (R42a) |
POST /auth/refresh | Sliding refresh (>1h-old tokens re-minted) | Session endpoint (R42a) |
POST /auth/logout / logout-all | Expire cookie / revoke all tokens | Session endpoints (R42a) |
POST/GET/PATCH /auth/admin/users* | Admin user lifecycle + invites/reset | R42a; users.manage / roles.grant gated; deactivate-only |
GET /auth/invites/{token} / POST /auth/invites/redeem | Public invite validation/redemption | R42a |
POST /auth/validate | Validate a JWT token (claims-only, legacy) | Used internally; returns {valid, user_id, roles} |
* /auth/forward-auth | Validate the aegis_token cookie or Bearer header | Used 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 /health | Health check | Returns {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_SECRETdefault 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/refreshre-mints tokens older than 1h) and revocation viatoken_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 byAEGIS_AUTH_MODE) — a direct service-port request without the headers gets401on protected routes in enforce mode - The gateway strips and re-applies CORS headers, ensuring backends cannot override the CORS policy