Appearance
OIDC
Agentcy's auth layer is provider-agnostic. When AUTH_PROVIDER=oidc, the API validates bearer tokens using RS256 against a remote JWKS. Any OIDC issuer works — Auth0, Supabase, Keycloak, Okta, Google, your own Dex instance, etc.
Configure
env
AUTH_PROVIDER=oidc
JWKS_URL=https://<issuer>/.well-known/jwks.json
JWT_ISSUER=https://<issuer>
JWT_AUDIENCE=agentcy # must match the `aud` claim you issueNo JWT_SECRET is needed — keys come from the JWKS endpoint. The API caches the JWKS for 1 hour (JWKS_TTL_SECS) and refreshes on unknown kid.
Mapping claims to Agentcy users
The auth middleware expects these claims from the OIDC provider:
| Claim | Purpose | Required |
|---|---|---|
sub | Upstream user id (stored as external_id) | yes |
email | Display + login | yes |
name | Display | optional |
https://agentcy.dev/org_id | Org this user belongs to | yes (unless using JIT) |
https://agentcy.dev/role | owner / admin / member / viewer | optional (defaults to member) |
Agentcy resolves a user row by (iss, sub). On first login, it creates the row. If the token has no org_id claim and JIT is on, it creates a new org.
Custom claim names are configurable:
env
OIDC_CLAIM_ORG_ID=https://myco.com/org
OIDC_CLAIM_ROLE=https://myco.com/role
OIDC_JIT_ORG=true # create orgs on the flyProvider recipes
Auth0
In Auth0, create an API with audience agentcy. In your Application, under APIs, enable that API.
Add a Rule or Action that injects custom claims:
javascript
exports.onExecutePostLogin = async (event, api) => {
const orgId = event.user.app_metadata.agentcy_org_id;
const role = event.user.app_metadata.agentcy_role || "member";
api.accessToken.setCustomClaim("https://agentcy.dev/org_id", orgId);
api.accessToken.setCustomClaim("https://agentcy.dev/role", role);
};Then in Agentcy:
env
AUTH_PROVIDER=oidc
JWKS_URL=https://<tenant>.auth0.com/.well-known/jwks.json
JWT_ISSUER=https://<tenant>.auth0.com/
JWT_AUDIENCE=agentcySupabase
Supabase signs its access tokens with its own JWKS. Use Supabase to authenticate, then call Agentcy with the access_token:
env
AUTH_PROVIDER=oidc
JWKS_URL=https://<project-ref>.supabase.co/auth/v1/keys
JWT_ISSUER=https://<project-ref>.supabase.co/auth/v1
JWT_AUDIENCE=authenticatedSet the org_id via a Postgres trigger that copies user.id → access_token custom claim (Supabase SQL cookbook — "custom access token hooks").
Keycloak
Create a client "agentcy" (access type: public, valid redirect URIs as per your frontend). Under Client Scopes → Protocol Mappers, add:
org_id→ user attribute, map tohttps://agentcy.dev/org_id, add to access token.role→ user attribute, map tohttps://agentcy.dev/role, add to access token.
Then:
env
AUTH_PROVIDER=oidc
JWKS_URL=https://<keycloak>/realms/<realm>/protocol/openid-connect/certs
JWT_ISSUER=https://<keycloak>/realms/<realm>
JWT_AUDIENCE=agentcyMigration from local auth
You can switch providers without losing data:
- Deploy a new environment with
AUTH_PROVIDER=oidcpointing at an issuer where users already have accounts. - Set
OIDC_MATCH_BY_EMAIL=true— on first OIDC login, the API links byemailif it already exists locally. - Validate a few logins.
- Remove local-only users (
DELETE FROM users WHERE auth_provider = 'local') once migration is done.
Token format
Agentcy treats the bearer token opaquely. The middleware:
- Parses header + payload without verifying.
- Looks up the kid in the cached JWKS.
- Verifies the RS256 signature.
- Checks
iss,aud,exp,nbf. - Populates
UserContext { user_id, org_id, role, external_id }.
Failures surface as 401 with error.code = "invalid_token" and a short reason.
Health check
GET /api/v1/health reports JWKS reachability:
json
"jwks": { "status":"ok", "url":"https://…/jwks.json", "keys":3, "last_refresh":"…" }If jwks.status = "error", the API is up but can't validate tokens — a common cause is a typo in JWKS_URL or a misconfigured outbound proxy.
Gotchas
- Audience mismatch is the most common failure. If tokens don't have
aud: agentcy(or whatever you set), validation fails. Some providers sendaud: ["agentcy","account"]— the validator accepts a string or array. - Issuer trailing slash.
JWT_ISSUER=https://foo.comvshttps://foo.com/are different strings. Match whatever your provider signs. - Clock skew. The validator allows 30 seconds by default (
JWT_CLOCK_SKEW_SECS). If your IdP runs ahead, bump this. - API keys still work. Creating an API key under an OIDC-authenticated session is fine — the key lives in Agentcy's DB and the middleware accepts both.
Next
- How-To: Local Auth — the email+password alternative.
- How-To: API Keys — programmatic access.
- Concept: Zero-Trust Policies — once auth is sorted, tighten authorization.