Skip to main content
Version: Next

Identity and Access

Agent Kernel authenticates every request through one configured identity provider and establishes a Principal for the run. The provider is configuration, not code: an identity block names it (an OpenID Connect issuer such as Auth0, Keycloak or Okta, Amazon Cognito, Microsoft Entra ID, Firebase, a static credential map, or your own class), and every surface the agent is reached through (REST, WebSocket, AG-UI, MCP, A2A, the CLI, the messaging integrations) resolves the caller through it. Tools and hooks read the same principal, threads and scheduled tasks belong to it, and a credential value is never written in a configuration file.

identity:
provider:
type: oidc
issuer: https://login.example.com/
audience: agent-kernel-api
from agentkernel.core import ToolContext

def whoami() -> str:
principal = ToolContext.get().principal
return f"{principal.subject} in {principal.namespace} with groups {principal.groups}"

Overview​

ConcernWhat it means
EnablementAn identity block selects the provider. Without one every request runs as the anonymous principal and an Authorization header is ignored.
ProviderOne per deployment: oidc, cognito, entra, firebase, static, or a dotted path to an IdentityProvider subclass.
CredentialA bearer token on HTTP (Authorization: Bearer <token>), ?token=<credential> on the WebSocket handshake, --token or AK_CLI_TOKEN on the CLI.
PrincipalThe subject, its namespace, groups, roles and claims, plus the agent acting on its behalf. Read it with ToolContext.get().principal in tools and Session.get_principal() in hooks.
AnonymousWith a provider configured, a request without a credential is anonymous and a credential that does not validate is a 401. Which routes refuse the anonymous principal is each surface's rule (below).
RolesThe token providers issue no roles; a static entry may list them. An optional role_mapper replaces them with roles derived from groups and listed claims. Nothing enforces roles yet: authorization arrives in a later phase.
Extraoidc, cognito, entra and firebase need agentkernel[auth] (PyJWT, cryptography, httpx). static needs nothing extra.

Configuration​

identity:
provider:
type: oidc # oidc | cognito | entra | firebase | static | a dotted path
issuer: https://login.example.com/ # required by oidc; derived by cognito, entra and firebase
audience: agent-kernel-api # required by oidc, cognito (the app client id) and entra (the application id)
jwks_uri: null # optional; discovered from the issuer when omitted
algorithms: [RS256, ES256] # allowed signing algorithms; symmetric ones are refused
leeway: 30 # seconds of clock skew tolerated on exp and nbf
required_claims: [] # claims a token must carry beyond the standard set
claims:
subject: sub # claim path of the subject
groups: groups # claim path of the group list, e.g. realm_access.roles
cognito: {region: eu-west-1, user_pool_id: eu-west-1_abc123, token_use: access}
entra: {tenant_id: 00000000-0000-0000-0000-000000000000}
firebase: {project_id: my-project}
static:
principals:
ALICE_TOKEN: # a secret key, resolved through the secret capability
subject: alice
kind: user # user | agent | service
groups: [ops]
roles: [] # roles issued directly; a role_mapper replaces them
claims: {email: alice@example.com}
role_mapper: # optional; absent leaves only a static entry's own roles
type: mapping # mapping | a dotted path to a RoleMapper subclass
mapping:
ops: [admin] # group name, or a value of a listed claim -> roles
claims: [] # claim paths whose values are looked up too; only groups by default
pass_through: null # a claim path whose string or list value becomes roles as it is

Every field has an environment form with the usual AK_ prefix and __ nesting: AK_IDENTITY__PROVIDER__TYPE=oidc, AK_IDENTITY__PROVIDER__ISSUER=..., AK_IDENTITY__PROVIDER__AUDIENCE=..., AK_IDENTITY__PROVIDER__COGNITO__USER_POOL_ID=....

A claim path is a claim name, a namespaced name such as https://acme.com/roles (tried as one key first, the way Auth0 issues custom claims), or dotted segments into a nested claim such as realm_access.roles.

Providers​

typeWhat it validatesSettingsNotes
oidcA signed JWT from any OpenID Connect issuer: signature against the issuer's JWK set, iss, aud, exp and nbfissuer, audience; optional jwks_uri, claimsAuth0, Keycloak, Okta and any other issuer. namespace is the issuer.
cognitoAn Amazon Cognito user pool tokencognito.region, cognito.user_pool_id, audience (the app client id); cognito.token_use access (default) or idGroups come from cognito:groups.
entraA Microsoft Entra ID v2.0 token (v1 tokens are refused)entra.tenant_id, audience (the application id)The subject is oid; the tid claim must match the tenant. roles and wids stay in claims.
firebaseA Firebase Authentication id tokenfirebase.project_idIssuer, audience and Google's JWK set are derived from the project. Groups default to the application's roles custom claim.
staticA credential from a configured mapstatic.principalsFor development, tests and the CLI. The keys name secrets; see below.
a dotted pathWhatever your class acceptsThe whole identity.provider blockSee bring your own provider.

Any OpenID Connect issuer (oidc)​

The provider reads <issuer>/.well-known/openid-configuration once to find the JWK set (or uses jwks_uri directly), caches the keys, and refreshes them once when a token names an unknown key, rate-limited so a flood of bad tokens cannot turn into a flood of fetches. An issuer that cannot be reached refuses the token with a 401, never a 500, and a failed first load is retried only after a short cooldown. A token must carry exp, iss and aud; aud must contain the configured audience. The principal's subject is the claims.subject path (sub by default), its namespace is the issuer, its groups the claims.groups path, and every claim but the subject arrives in claims for the role mapper and the tools, the groups claim included, so role_mapper.pass_through can lift it as it is. algorithms is checked when the provider is built: an empty list, none, an unknown name or a symmetric HS* algorithm is a configuration error at startup.

Auth0 is served by this provider. Use the tenant's issuer (or the custom domain) with the trailing slash as Auth0 issues it, the API identifier as the audience, and the namespaced claim an Auth0 Action sets as the groups path:

identity:
provider:
type: oidc
issuer: https://<tenant>.<region>.auth0.com/ # or the custom domain; trailing slash included, as Auth0 issues it
audience: <the API identifier of your Auth0 API>
claims:
groups: https://<your namespace>/roles # the namespaced claim an Auth0 Action sets; omit if you set none

permissions, org_id and the other Auth0 claims reach the role mapper and the tools through claims; the mapper looks a claim up only when role_mapper.claims lists it (claims: [permissions]). A token to test with comes from the tenant's /oauth/token endpoint, for example through the client-credentials flow of a machine-to-machine application authorised for the API.

Keycloak: the realm is the issuer, the client id is the audience (give the client an audience mapper), and realm roles sit in a nested claim:

identity:
provider:
type: oidc
issuer: https://<host>/realms/<realm>
audience: <client id>
claims:
groups: realm_access.roles

Okta: the authorization server is the issuer and its custom audience the audience; a groups claim configured on the server fills the groups:

identity:
provider:
type: oidc
issuer: https://<domain>/oauth2/<authorization server id>
audience: <the audience of the authorization server>
claims:
groups: groups

Amazon Cognito (cognito)​

identity:
provider:
type: cognito
audience: <app client id>
cognito:
region: eu-west-1
user_pool_id: eu-west-1_abc123
token_use: access # or id

The issuer is derived from the region and the pool. A Cognito access token carries no aud: its audience is client_id, which is checked against the configured audience; an id token carries aud and is checked as any other. Groups come from cognito:groups.

Microsoft Entra ID (entra)​

identity:
provider:
type: entra
audience: <application id>
entra:
tenant_id: <tenant id>

The provider accepts v2.0 tokens only: the issuer is derived from the tenant as https://login.microsoftonline.com/<tenant>/v2.0, and the token's tid must match it. Entra issues v2.0 access tokens for your API only when its app registration sets requestedAccessTokenVersion: 2 (api.requestedAccessTokenVersion in the manifest; Terraform azuread_application: api { requested_access_token_version = 2 }). Without it the access tokens are v1, carry the issuer https://sts.windows.net/<tid>/, and are refused.

The subject is oid, the groups claim is groups, and roles and wids stay in claims, where the role mapper reads them when role_mapper.claims lists them. A token carrying the groups-overage marker for the configured groups claim (more groups than fit in a token) yields no groups, with one warning per process: resolving them needs a Graph call this provider does not make. App roles read as groups (claims.groups: roles) are not affected by the marker.

Firebase Authentication (firebase)​

identity:
provider:
type: firebase
firebase:
project_id: <project id>

The issuer, the audience and Google's JWK set are derived from the project. Firebase has no built-in role claim, so the groups path defaults to the application's roles custom claim; every custom claim stays in claims. A boolean flag such as admin: true needs a role mapper of your own.

Static credentials (static)​

identity:
provider:
type: static
static:
principals:
ALICE_TOKEN:
subject: alice
groups: [ops]
BOB_TOKEN:
subject: bob
kind: service
claims: {team: finance}

Each key names a secret, not a value: ALICE_TOKEN is resolved through the secret capability when the provider is built (the process environment first, then the configured secret provider), so the credential a client sends lives in an environment variable, an SSM parameter or a mounted Kubernetes Secret, never in the file. A key with no secret is a configuration error at startup, and so is an empty principals map; two keys resolving to the same value are refused. An entry may list roles of its own; a role_mapper replaces them. Authentication compares the presented credential against every resolved value in constant time, and the principal's namespace is static. This is the provider the examples and the CLI run on.

Bring your own provider​

Set identity.provider.type to a dotted path to an IdentityProvider subclass. The class receives the whole identity.provider block and reads settings of its own from config.model_extra (the block keeps fields it does not define), and the configured role mapper wraps it like any built-in:

from agentkernel.identity import AuthenticationError, IdentityProvider, RequestContext
from agentkernel.core.model import Principal


class ApiKeyProvider(IdentityProvider):
name = "api_key" # the value of Principal.provider

def __init__(self, config): # the identity.provider block
self._keys = load_keys(config.model_extra["keys_table"]) # a setting of this provider's own

async def authenticate(self, credential: str, context: RequestContext | None = None) -> Principal:
key = self._keys.get(credential)
if key is None:
raise AuthenticationError("unknown api key") # logged, never returned to the caller
return Principal(subject=key.id, kind="service", provider=self.name, namespace="acme", claims={"tier": key.tier})

def security_scheme(self):
return ("apiKey", {"in": "header"})
identity:
provider:
type: myapp.identity.ApiKeyProvider
keys_table: acme-api-keys

A provider returns the facts it can vouch for (subject, groups, claims, the namespace the subject is unique within) and never writes roles; context carries the request path, method and headers when the surface knows them. agentkernel.identity.provider.testing.IdentityProviderContract and RoleMapperContract are reusable pytest contract suites for provider and mapper implementations.

Role mapper​

Providers assert groups and claims; the role_mapper turns them into Agent Kernel roles, replacing any a static entry listed. The built-in mapping type looks every group name up in mapping and appends the roles found. A claim is looked up only when its path is listed in claims, and then its string value (or each string item of a list) is looked up the same way. Only groups are looked up by default because a user sets many of their own claims (a display name, a sign-up username, an email address): matching every claim value would let one of them grant a role, so list only claims your issuer controls. Last, the pass_through claim's string or list value is appended as roles as it is. Boolean flags and nested claim objects are not mapped; bring a RoleMapper subclass by dotted path for those. A dotted-path mapper receives the whole identity.role_mapper block, its own settings in config.model_extra.

identity:
provider:
type: oidc
issuer: https://<host>/realms/<realm>
audience: <client id>
claims:
groups: realm_access.roles
role_mapper:
type: mapping
mapping:
ops: [admin] # a realm role, read as a group
finance: [reader]
report-viewer: [reader] # a client role, a value of the listed claim
claims: [resource_access.<client id>.roles] # client roles, which the realm assigns
pass_through: https://acme.com/roles

The principal​

Principal (agentkernel.core.model) is who a run acts as:

FieldMeaning
subjectThe stable identifier the provider resolved: an OIDC sub, Entra's oid, a static entry's subject, a messaging platform's user id.
kinduser (default), agent or service.
providerThe provider's short name (oidc, static, ...), or messaging:<platform> and realtime:<gateway> for the integrations.
namespaceThe scope the subject is unique within: the OIDC issuer, static, a Slack workspace id.
groupsThe memberships the provider asserted.
rolesWhat the role mapper derived; without one, a static entry's own roles (the token providers issue none). Nothing enforces them yet.
claimsEvery other fact the provider kept, for the role mapper and the tools.
actorThe party acting on the subject's behalf: the agent principal (kind: agent, provider: agentkernel) the runtime attaches while a run executes.

Principal.anonymous() is the principal of a request nobody authenticated; principal.is_anonymous tells the two apart.

The runtime publishes the principal for the duration of a run. Tools read it with ToolContext.get().principal, hooks and any code running inside the session with Session.get_principal(), and Session.get_owner() returns a SessionOwner (subject, provider, namespace), which records the first authenticated principal a session was used by. Threads and scheduled tasks keep their own owner (the thread's user_id, the task's principal):

from agentkernel.core import AgentReplyText, PreHook, Session

class OpsOnly(PreHook):
async def on_run(self, session: Session, agent, requests):
principal = session.get_principal()
if "ops" not in principal.groups:
return AgentReplyText(response="This agent serves the ops group only.") # replies without running the agent
return requests

A principal a client puts in a request body is never trusted: the surface that authenticated the request overwrites it.

What each surface does​

SurfaceCredentialWithout a credentialRefused credential
REST chat (/api/v1/chat, /api/v1/chat-multipart; SSE with execution.message_type: delta)Authorization: BearerRuns anonymous401
Conversation threads (/api/v1/threads, the thread chat routes)Authorization: Bearer401 Authentication required: a thread belongs to its principal and listing is scoped to the caller401
Scheduled tasks (/api/v1/schedules, a schedule block on a chat request)Authorization: Bearer401 on the management routes; a chat request with a schedule block is 403 (scheduling requires an authenticated principal)401
WebSocket gateway (/ws: the Kubernetes gateway tier, single-process async mode)?token=<credential> on the handshakeThe socket is accepted, then closed with code 1008 and the reason Authentication token is requiredClosed with 1008 and Authentication failed
AWS ECS WebSocket and the Lambda $connect?token=<credential> on the handshakeThe handshake is refused: 401 Authentication token is required401 Authentication failed
AG-UIAuthorization: Bearer401: AG-UI runs an agent on a caller's behalf401
MCPAuthorization: Bearer, enforced by FastMCP401 once an identity block exists; open and anonymous without one. With an OpenID-family provider the 401 points at the RFC 9728 resource metadata for mcp.url (MCP guide)401
A2AAuthorization: BearerRuns anonymous401
CLI--token <credential> or AK_CLI_TOKENRuns anonymousExits with Authentication failed
Messaging integrationsThe platform's own verification; the webhook routes skip the identity dependency, so a platform token in Authorization (Teams) reaches itA messaging:<platform> principal: the platform user id as subject, the workspace or tenant as namespace, platform facts in claims. A webhook whose verification secret is unset (whatsapp.app_secret, messenger.app_secret, instagram.app_secret, telegram.webhook_secret) logs a warning and runs its deliveries anonymousn/a
LiveKit realtimeThe gateway's own verificationA realtime:<gateway> principaln/a

Two consequences for request bodies: user_id is not a request field any more (a body carrying it is a 400 with user_id is not a request field; the acting user comes from the credential), and the thread and schedule listings take no user_id query parameter: they return the caller's own records. Thread and task objects still report their owner in a user_id field. The thread and schedule handlers (AgentThreadRequestHandler, ThreadRequestHandler, ScheduleRESTRequestHandler) and AGUIRequestHandler take no arguments and refuse to construct without an identity block, as do the WebSocket modes.

On the routes RESTAPI serves, a 401 for a refused bearer token, and the 401 Authentication required of a route that needs an authenticated caller, carry WWW-Authenticate: Bearer. A WebSocket route of your own on a router RESTAPI mounts goes through the same identity dependency: a refused bearer header closes the socket with 1008 and the public reason instead.

The sandbox's default principal resolver reads the run principal, so a profile with identity.mode: user runs code as the authenticated caller with no custom resolver. A run for a messaging or realtime sender (a messaging: or realtime: principal, whose subject is a platform id) resolves to agent mode like an anonymous one, so a user-mode profile refuses it. A profile left on agent mode keeps running as the agent whoever calls; see the sandbox guide.

Deployments​

The identity block belongs in the configuration of the process that authenticates, and the credential of a static provider reaches that process as an environment variable or a mounted Secret.

  • AWS Lambda (guide): the API Gateway authorizer Lambda is one line, handler = APIGatewayAuthorizer().handle; it authenticates the bearer token through the provider and returns an Allow policy whose context carries the principal; a refused, expired or malformed token raises Unauthorized (a 401 from API Gateway), and a request with no token is a Deny (403). The request-handler Lambda puts that principal on the chat request; a custom route registered with Lambda.register reads it with Lambda.principal(event). Webhook routes that carry their own verification bypass the authorizer with APIGatewayAuthorizer(bypass=WebhookRouteMatcher.for_integrations("slack")). For WebSocket APIs the $connect Lambda is handler = WebsocketConnectionHandler.handler, which authenticates ?token= and stores the principal with the connection; a custom WebSocket route registered with Lambda.register reads that connection's principal with Lambda.principal(event) as well. The authorizer module keeps its handler_path contract and the connections table its user_id hash key: the Terraform modules are unchanged.
  • AWS ECS (guide): the REST service authenticates every request; in WebSocket mode the service authenticates the handshake's ?token=, and custom WebSocket routes receive {"message": ..., "principal": ...}.
  • Azure Functions (guide): AzureFunctions.handler authenticates the bearer header before parsing the request and answers a refused credential with 401 and {"error": ..., "session_id": null}.
  • Kubernetes (guide): the io handler and the WebSocket gateway build the provider, so a static credential reaches them through the chart's per-tier ioHandler.extraEnv and wsGateway.extraEnv from a Secret, which keeps it off the agent runner; the secretStore mount reaches only the agent-executing tier.
  • GCP (guides): the examples authenticate Google identity tokens at the API Gateway, and the application runs as the anonymous principal. The gateway calls Cloud Run with its own token in Authorization and forwards the caller's header as X-Forwarded-Authorization, which the built-in providers do not read. A provider of your own can, since context.headers carries it; the guides say when the forwarded headers can be trusted.

Migrating from agentkernel.auth​

The agentkernel.auth package (AuthValidator, Authoriser, the JWT and JWKS validators) and the handler-level hooks that took them are gone. Authentication is configuration now:

BeforeNow
RESTAPI.add_auth_handlers([...]), WebSocketGateway.run(auth_validator=...), IOHandler.run(auth_validator=...)An identity block; WebSocketGateway.run(), IOHandler.run(handlers=[...])
AgentThreadRequestHandler(authoriser), ThreadRequestHandler(authoriser), a schedule handler with an AuthoriserAgentThreadRequestHandler(), ThreadRequestHandler(), ScheduleRESTRequestHandler(); ownership is the principal's
AGUIRequestHandler(authoriser)AGUIRequestHandler()
A JWT validator class per deploymenttype: oidc (or cognito, entra, firebase)
A Lambda authorizer that wraps a validatorhandler = APIGatewayAuthorizer().handle
GCPAuthorizerGone; the API Gateway's own JWT check, or an oidc block
user_id in request bodies and ?user_id= on listingsThe credential; a body carrying user_id is a 400
acting_user_id on Runtime.runprincipal on Runtime.run, AgentService.run_multi and stream_multi
Custom validation logicA bring-your-own IdentityProvider by dotted path, or a RoleMapper

Threads and scheduled tasks recorded before the change carry a user id without a namespace; recreate or migrate them outside the system.

Examples​

Every example runs on the static provider with its credential read from the environment: ALICE_TOKEN and BOB_TOKEN for the two-user API demos, DEMO_TOKEN for the single-user ones, and DEMO_AUTH_TOKEN for the AWS and Kubernetes deployments (a sensitive demo_auth_token Terraform variable, or the demo-credentials Secret on Kubernetes).

Ready to Ship Your
First Agent?

Free, open-source, Apache 2.0. No licensing costs, no vendor lock-in. Join hundreds of developers building production AI agents with Agent Kernel.

Agent Kernel
Ask Agent Kernel