aegis · Concepts
Identity and access
How aegis verifies callers with JWT, JWKS or its own issuer, resolves tenants, and authorizes each tool call with role-based allow, deny and approve policies.
On this page
aegis answers two questions for every call: who is calling, and what they may do. Identity comes from a bearer JWT. Permissions come from roles and policies in Postgres. Both are scoped to a tenant.
Authentication#
When authentication is on, every request to /v1/invoke, /mcp, /v1/list-tools, /v1/subscribe and /admin/v1/* must carry a JWT:
Authorization: Bearer <jwt>
The other routes take no user JWT. /healthz, /dashboard and the OAuth endpoints are open. /metrics checks its own METRICS_TOKEN, unless you set METRICS_PUBLIC=1. /billing/webhook checks a Stripe signature, and SCIM has its own bearer tokens.
Verifiers#
aegis tries the configured verifiers in order, and the first that validates the token wins. Each is pinned to one algorithm and holds only its own key, so an HS256 token can never pass an RS256 verifier. That rules out algorithm confusion.
Every token must carry an exp claim and a string subject claim. AUTH_SUBJECT_CLAIM sets which claim holds the subject. It defaults to sub; with Microsoft Entra ID you might use oid. If you set AUTH_ISSUER or AUTH_AUDIENCE, the JWKS verifier enforces it fail-closed, so a token without that claim is rejected.
A JWKS token must carry a kid header. An unknown kid triggers a refetch of the key set at most once every 5 seconds, so key rotation is picked up quickly and random kid values can't flood your identity provider.
A missing, expired or invalid token gets HTTP 401 with a body of the form {"error":"unauthorized: ..."}.
If no verifier is configured, authentication is off. Every caller becomes the subject anonymous in the default tenant, and the gateway logs Auth: DISABLED at startup. Run it that way only on your own machine.
Tenants#
Every caller belongs to one tenant, read from the claim that AUTH_TENANT_CLAIM names (default tenant). If that claim is missing or empty, the caller is in the default tenant. That means a single-tenant deployment needs no tenant claim, and an unscoped token never sees every tenant.
AUTH_ALLOWED_TENANTS sets which tenants a gateway instance serves:
- Unset: the instance serves every tenant (shared mode).
- A comma-separated list: only the listed tenants are served. A valid token for any other tenant gets HTTP 401.
- Set but empty: no tenant is served. The gateway fails closed and logs an error. It does not fall back to shared mode.
The check runs after the token validates, and it also applies to anonymous callers. SCIM tokens for tenants outside the list are dropped at startup. In the Helm chart, tenancy.mode: isolated sets it from tenancy.tenants and refuses to render an empty list. The namespace guardrails that isolated mode also renders are a commercial module.
Tenant isolation goes further than the policy check. Each of these layers is scoped by tenant:
Names only have to be unique within a tenant. Two tenants can each have a server called github and a role called admin.
The default tenant is also the operator tenant. Three global actions are open only to callers in default: verifying the audit chain, setting a tenant's billing tier, and switching kill-switches on or off. They check the caller's tenant as well as its admin permission. Give every customer its own tenant claim, and grant roles in default only to your operators.
Authorization (RBAC)#
Subjects are granted roles, and roles carry policies. A policy has three parts:
- Server: one server, or none. A policy with no server applies to every server in the tenant.
tool_pattern: a glob in which*matches any run of characters, such asbeta_*,*_echoor*.- Effect:
allow,denyorapprove.
For each tools/call, aegis collects the rules that match the caller's roles, the target server and the tool name, and then decides:
A subject with no roles, including one aegis has never seen, is denied every call and sees an empty tool list. tools/list hides only denied tools, so tools that need approval stay visible. Routing and the kill-switch run before RBAC: another tenant's tool returns -32601, and a killed tool returns -32062 whatever the policy says. Approvals are covered in Hold, approve and stop tool calls.
aegis evaluates policy against an in-memory snapshot, which it reloads every POLICY_REFRESH_SECS (default 30). Grants and revocations take effect within that window. If a reload fails, aegis keeps enforcing the previous snapshot.
RBAC requires DATABASE_URL. Without it, the gateway runs a single upstream and allows every call it can route, and the admin API returns HTTP 503. Set DATABASE_URL before anyone else can reach the gateway.
Managing policy#
You can change policy in three ways, and all of them write to the same Postgres tables:
- SQL seed scripts in
deploy/postgres/init, for a new database.03-rbac-seed.sqlis the development and CI seed that the Compose stack loads, and it shows the format. It grantsadminto test subjects, so write your own seed for production. - The admin API. The
/dashboardconsole reads roles, policies and users through it once you connect an operator token, but edits made in the console are not sent to the gateway. Change policy through the API itself. - SCIM (commercial), which controls who holds each role but never creates or edits policies.
The example below runs against the Compose stack, with $ADMIN holding a token for the seeded admin subject e2e-tester (the Quick start shows how to mint one). It creates a role, lets that role call the beta_ tools on mock-beta, and grants the role to a subject:
curl -s -X POST http://localhost:8080/admin/v1/roles -H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' -d '{"name":"analyst"}'
curl -s -X POST http://localhost:8080/admin/v1/policies -H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' -d '{"role":"analyst","server":"mock-beta","tool_pattern":"beta_*","effect":"allow"}'
curl -s -X POST http://localhost:8080/admin/v1/users/ci-agent/roles -H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' -d '{"role":"analyst"}'
If you leave out server, the policy applies to every server. tool_pattern defaults to *. A policy can only name a role that already exists and a server in your tenant. Otherwise the request returns HTTP 404. Granting a role to a subject aegis doesn't know yet creates that subject.
A duplicate role name returns HTTP 409. A role name, tool_pattern or effect that fails validation returns HTTP 400. A pattern can be 1 to 128 characters and can't contain whitespace.
Admin permissions#
The admin API goes through the same RBAC engine as tool calls. Its permissions are checked against a virtual server named aegis-admin. GET endpoints need catalog:read. Changes to servers, roles, policies, grants, quarantine, approvals and kill-switches need catalog:write. aegis-admin is a disabled catalog entry that never routes traffic, and registering a real server with that name returns HTTP 400.
This policy creates a read-only role for auditors:
{"role":"auditor","server":"aegis-admin","tool_pattern":"catalog:read","effect":"allow"}
In any tenant other than default, aegis creates that tenant's aegis-admin entry the first time a policy names it.
The seeded admin role has one rule: * on every server. That rule also covers aegis-admin, which is how it grants both admin permissions. The same is true of any role you create. A policy with no server also applies to aegis-admin, so a pattern such as * gives admin access. Scope the policies of non-admin roles to named servers.
A lock-out guard checks every RBAC change that could remove access, from the admin API or SCIM. The change runs in one transaction and is rolled back if no subject in the tenant would still hold catalog:write on aegis-admin: the admin API returns HTTP 400 and SCIM returns HTTP 409. The guard evaluates the resulting rules, so it also catches a new deny that would override the admin grant. It refuses only changes that remove the last administrator, so a new tenant with none yet is not blocked.
Enterprise identity (commercial)#
These modules are under the commercial license (LicenseRef-AEGIS-Commercial), and each stays off until you configure it.
OAuth 2.1 token issuance#
Set OAUTH_ISSUER to have aegis issue its own RS256 tokens through the authorization-code flow. aegis also adds a matching verifier, so the tokens it issues work against its own API.
PKCE with S256 is required, and plain is rejected. client_id and redirect_uri must match OAUTH_CLIENT_ID and OAUTH_REDIRECT_URIS exactly. A code is stored in Redis for 60 seconds and works once. Tokens last for OAUTH_TOKEN_TTL_SECS seconds (default 3600). Their iss is OAUTH_ISSUER and their aud is OAUTH_AUDIENCE, which defaults to the issuer.
Issued tokens carry sub but no tenant claim, so their callers are in the default tenant. The local verifier reads the subject from AUTH_SUBJECT_CLAIM like the others, so issued tokens validate only while that is sub. Set OAUTH_SIGNING_KEY_PEM to a PKCS#8 RSA key before production. Without it, aegis generates a temporary key at startup and logs a warning, and tokens signed with that key stop validating after a restart and on other replicas.
SCIM 2.0 provisioning#
The SCIM receiver lets your identity provider push users and group memberships into the RBAC tables. It needs DATABASE_URL and one of these:
SCIM_BEARER_TOKEN, which provisions intoSCIM_TENANT(defaultdefault)SCIM_BEARER_TOKENS, a comma-separated list oftenant:tokenpairs, one for each identity provider
SCIM authenticates with its own bearer tokens, compared in constant time, not with user JWTs. The tenant comes from the token, never from the request, and tokens for tenants outside AUTH_ALLOWED_TENANTS are dropped at startup.
A user's userName must equal the subject claim your identity provider puts in its tokens, or the user authenticates with no roles. Creating a group creates its role if needed, and deleting a group deletes the role along with its policies. SCIM never creates or edits policies.
Group members are referenced by the id that POST /scim/v2/Users returned. A PATCH on a user with the body {"active": false} removes that user's memberships, and DELETE removes the user. Both take effect within POLICY_REFRESH_SECS.
For the full request and response formats, see the API reference. For every refusal code on this page, see Error codes.