Authentication
The AEGIS API uses a two-step authentication flow: exchange email/password credentials for a JWT token, then use the token on subsequent requests. The Go API gateway enforces authentication on every request except health checks and the token endpoint itself.
Authentication Flow
1. Client sends email + password to POST /api/v1/auth/token
2. Auth service verifies credentials (bcrypt), returns JWT
3. Client includes JWT in Authorization header (or aegis_token cookie) on all subsequent requests
4. Gateway validates JWT on each request before proxying to backendObtaining a JWT Token
Exchange email and password credentials for a JWT token by calling the token endpoint. This endpoint does not require prior authentication.
curl -X POST http://localhost:8000/api/v1/auth/token \
-H "Content-Type: application/json" \
-d '{"email": "admin@aegis.local", "password": "aegis-dev-admin"}'Request Body
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | The user’s email address (matched case-insensitively) |
password | string | Yes | The user’s password |
Response
Since R42b the JWT rides only in the Set-Cookie header — the body carries
identity fields for immediate UI hydration, no access_token:
Set-Cookie: aegis_token=eyJhbGciOiJIUzI1NiIs...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400{
"expires_in": 86400,
"user_id": "52c8f756-...",
"roles": ["admin", "operator", "reviewer"],
"email": "admin@aegis.local",
"display_name": "Bootstrap Admin"
}| Field | Type | Description |
|---|---|---|
expires_in | integer | Token lifetime in seconds (86400 = 24 hours) |
user_id | string | The authenticated user’s ID |
roles | string[] | Roles granted to this user |
email | string | The authenticated user’s email |
display_name | string | null | Human-readable name for the UI header |
Programmatic clients capture the cookie value and may send it back either as the
aegis_token cookie or as Authorization: Bearer <value> — the gateway accepts
both.
If the email or password is incorrect (or the user is inactive), the endpoint returns 401 Unauthorized with {"detail": "Invalid email or password"}.
Using the JWT Token
Include the JWT in the Authorization header with the Bearer scheme on all subsequent requests:
curl http://localhost:8000/api/v1/conversations \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."For browser clients (SSE/EventSource), the gateway also accepts the JWT from the aegis_token cookie set at login, so no Authorization header is required.
# Step 1: Get a token (from the Set-Cookie header — the body no longer carries it)
TOKEN=$(curl -si -X POST http://localhost:8000/api/v1/auth/token \
-H "Content-Type: application/json" \
-d '{"email": "admin@aegis.local", "password": "aegis-dev-admin"}' \
| grep -i '^set-cookie: aegis_token=' | sed 's/^[Ss]et-[Cc]ookie: aegis_token=//; s/;.*//')
# Step 2: Use the token
curl http://localhost:8000/api/v1/conversations \
-H "Authorization: Bearer $TOKEN"Token Details
JWT Claims
The JWT payload contains the following claims:
| Claim | Description |
|---|---|
user_id | The authenticated user’s identifier |
email | The authenticated user’s email address |
roles | Array of role keys (e.g., ["admin", "operator", "reviewer"]), sourced from user_roles at mint time |
tenant_id | The user’s tenant, forwarded to backends as X-Tenant-Id |
tv | Token version (R42a) — checked against users.token_version at resolve time; logout-all / deactivation / password reset bump it and revoke all outstanding tokens. Missing (pre-R42a token) = 0 |
iss | Issuer — "aegis-auth" for tokens from the auth service |
iat | Issued-at timestamp (Unix epoch) |
exp | Expiration timestamp (Unix epoch) |
The JWT carries identity; authorization state (roles, permissions, active status) is re-resolved from the database at the perimeter via POST /auth/resolve (R42a, ~5s cache) — so role changes and deactivation bind on live traffic within seconds, not at next login. Sessions can be refreshed (POST /auth/refresh), ended (POST /auth/logout), or revoked everywhere (POST /auth/logout-all) — see the auth endpoints page.
Token Expiry
Tokens expire 24 hours after issuance. When a token expires, the gateway returns 401 Unauthorized. Request a new token by calling POST /api/v1/auth/token again.
Signing Algorithm
Tokens are signed with HS256 (HMAC-SHA256) using a shared secret configured via the JWT_SECRET environment variable.
Token Validation
The gateway first validates tokens locally using the shared JWT secret (fast reject), then re-resolves authorization state from the database via auth-service’s internal POST /auth/resolve (~5s cache, stale-while-revalidate — see the auth flow page). The auth service also exposes a legacy claims-only validation endpoint:
# The token is passed as the `authorization` parameter, not a JSON body
curl -X POST "http://localhost:8000/api/v1/auth/validate?authorization=Bearer%20eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."Response:
{
"valid": true,
"user_id": "dev-user",
"roles": ["admin", "operator", "reviewer"]
}Role-Based Access
Some endpoints require specific roles. The gateway checks roles from the DB-fresh resolve result before proxying the request:
| Endpoint Pattern | Required Role |
|---|---|
/api/v1/admin/* | admin |
/api/v1/detection-rules/* | power_user or admin |
POST /api/v1/approvals/{id}/decide | reviewer or admin (R42c) |
| Config-type writes (entity/event types, guard rules) | power_user or admin (R42c) |
| All other endpoints | Any authenticated user |
If the user’s roles do not include the required role, the gateway returns 403 Forbidden:
{
"error": "admin role required"
}Since R42d, backend services additionally enforce fine-grained
permissions (e.g. hitl.approve on approval decisions, filings.submit on
checklist submission, skills.author on skill edits) from the
gateway-stamped X-Permissions header, per the R42 Appendix C matrix — see
Service-Side Enforcement.
A failed permission check returns 403 with {"detail": "Insufficient permissions"}
(while AEGIS_AUTH_MODE=log, the failure is warn-logged and allowed through
during the rollout soak).
Public Endpoints
The following endpoints do not require authentication:
| Endpoint | Description |
|---|---|
GET /health | Gateway health check |
POST /api/v1/auth/token | Token exchange |
GET /api/v1/auth/invites/{token}, POST /api/v1/auth/invites/redeem | Invite validation/redemption (public since R42b) |
GET /api/v1/entity-types* | Public entity type listings |
GET /api/v1/relationship-rules* | Public relationship rule listings |
GET /api/v1/relationship-types* | Public relationship type listings |
User Provisioning
Accounts are admin-provisioned — there is no self-serve signup. Users live in the users table with role grants in user_roles (see the auth-service page). Since R42e, user identity is a uuid end-to-end: every identity column across the platform references users(id) with a database-level foreign key.
The primary path is the Users & Roles admin page at /admin/users (R42d — see the user guide), backed by the admin API: POST /api/v1/auth/admin/users creates an invited account and returns a one-time invite URL (7-day expiry); the invitee redeems it to choose a password and is logged in. A CLI fallback remains:
cd services/auth-service
poetry run python -m auth_service.create_user \
--email alice@example.com \
--roles admin,operator,reviewerThe command prompts for a password (or reads it from AEGIS_NEW_USER_PASSWORD). Re-running it for an existing email rotates that user’s password and revokes their live sessions. Accounts are deactivate-only (--deactivate / --reactivate, or PATCH /auth/admin/users/{id}) — there is no hard delete.
User Directory
Admins can list the tenant’s users via GET /api/v1/auth/users — it returns each user’s id, email, display_name, roles, and is_active. The auth service decodes the JWT in-service (Bearer header or aegis_token cookie) rather than trusting forwarded headers, and returns 403 for non-admin callers. This directory feeds the Platform Settings page’s per-user budget-override picker. See the auth endpoints page for an example.
System Users (R42e)
Four non-loginable system accounts exist for FK integrity and attribution: system@aegis.local (the nil UUID 00000000-0000-0000-0000-000000000000), legacy@aegis.local (backfill target for unmappable pre-R42 identity strings), eval-runner@aegis.local, and demo-seed@aegis.local. Each has an empty password hash, which fails closed at login — they can never authenticate — while is_active stays true so they satisfy foreign keys and appear in attribution joins. The pre-R42 admin-author sentinel UUID is retired; rows it owned were remapped to the real admin.
Bootstrap Admin
On startup the auth service seeds an initial admin from the BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_ADMIN_PASSWORD environment variables, if a user with that email does not already exist. For local development these default to admin@aegis.local / aegis-dev-admin.
| Password | Roles | |
|---|---|---|
admin@aegis.local | aegis-dev-admin | admin, operator, reviewer |
The default bootstrap admin credentials must never be used in production. Set strong BOOTSTRAP_ADMIN_EMAIL / BOOTSTRAP_ADMIN_PASSWORD values and a real JWT_SECRET via environment variables before deploying.
Security Notes
- Passwords are hashed with bcrypt before storage; plaintext passwords are never persisted.
- Email matching is case-insensitive (enforced by a
LOWER(email)unique index). - The
JWT_SECRETmust be set via an environment variable in production. - CORS is configured to allow all origins (
*). Restrict this in production deployments.