Skip to content

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 issue

No 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:

ClaimPurposeRequired
subUpstream user id (stored as external_id)yes
emailDisplay + loginyes
nameDisplayoptional
https://agentcy.dev/org_idOrg this user belongs toyes (unless using JIT)
https://agentcy.dev/roleowner / admin / member / vieweroptional (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 fly

Provider 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=agentcy

Supabase

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=authenticated

Set 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 to https://agentcy.dev/org_id, add to access token.
  • role → user attribute, map to https://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=agentcy

Migration from local auth

You can switch providers without losing data:

  1. Deploy a new environment with AUTH_PROVIDER=oidc pointing at an issuer where users already have accounts.
  2. Set OIDC_MATCH_BY_EMAIL=true — on first OIDC login, the API links by email if it already exists locally.
  3. Validate a few logins.
  4. 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:

  1. Parses header + payload without verifying.
  2. Looks up the kid in the cached JWKS.
  3. Verifies the RS256 signature.
  4. Checks iss, aud, exp, nbf.
  5. 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 send aud: ["agentcy","account"] — the validator accepts a string or array.
  • Issuer trailing slash. JWT_ISSUER=https://foo.com vs https://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

Built by AgentcyLabs. For in-house deployment or Agentcy Cloud (PaaS) access, visit agentcylabs.com.