SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Give your AI agent its own identity

An AI agent using a copy of your API key is invisible in the audit log. Give it its own OIDC client in authentik, scope its token, and revoke only that one.

Why your AI agent needs its own identity

Give your AI agent its own identity and it stops borrowing yours. The agent gets its own client registration in the identity provider (IdP) you already run, asks that IdP for a short lived token, and presents the token to every service it touches. Your own credentials stay yours. The agent's credentials can be narrowed to one job, watched separately, and deleted on their own.

The mechanism is OIDC (OpenID Connect) and the client credentials grant, the OAuth 2.0 flow built for a program acting as itself rather than on behalf of a signed in person. Every example below uses authentik, because that is the IdP most self-hosted stacks already have. The menu names differ elsewhere. The protocol does not.

What breaks when the agent uses your API key

The audit log cannot tell the agent from you. Every request carries your subject, so every log line names you. When a record is deleted at 03:00, the log says you deleted it. The log is the only record that survives an incident, and it is now wrong about the most important field in it.

You cannot narrow it. A key issued to a person carries that person's permissions. If you can delete production databases, so can the agent, because the service sees one identity with one permission set. The instinct to make a second, smaller account is the right one. The rest of this page is that instinct done properly.

Revoking it locks you out. The key is yours, so rotating it stops the agent and stops everything else you pasted it into. During an incident, when speed is the whole point, the one action that stops the agent also stops your own access to the thing you are trying to fix. That is why shared keys are rarely revoked fast: the cost of revoking one is your own working day.

Register the agent as its own OIDC client in authentik

Go to Applications > Providers > Create and pick OAuth2/OpenID Provider. The fields that matter:

  • Name: deploy-agent. Use one provider per agent, per job.
  • Authorization flow: any flow works here, because the client credentials grant never shows a browser consent screen.
  • Client type: Confidential. A public client has no secret, and this grant needs one.
  • Redirect URIs: leave it empty. There is no browser redirect in this flow.
  • Client ID and Client Secret: authentik generates both. Copy the secret now.

Then create the matching application under Applications > Applications > Create. Name it deploy-agent, give it the slug deploy-agent, and select the provider you just made. That slug appears in every URL the agent will use, so a typo here surfaces later as a 404 you will blame on something else.

Confirm the endpoints exist before you write a line of agent code:

curl -s https://sso.example.com/application/o/deploy-agent/.well-known/openid-configuration \
  | jq -r '.issuer, .token_endpoint'

You should see an issuer ending in /application/o/deploy-agent/ and a token endpoint at https://sso.example.com/application/o/token/. Note that the token endpoint is shared by every provider, while the issuer is per application.

One thing happens behind the scenes and it is worth knowing. When a client authenticates with only a client ID and client secret, authentik issues the token to a service account it creates for that purpose, named ak-<provider name>-client_credentials. For the provider above, that is ak-deploy-agent-client_credentials. That account is the agent's identity in every screen and every log from here on. After the first successful token request, open Directory > Users and check that it is there.

Get a token with the client credentials grant

TOKEN=$(curl -s -X POST https://sso.example.com/application/o/token/ \
  -d grant_type=client_credentials \
  -d client_id="$AGENT_CLIENT_ID" \
  -d client_secret="$AGENT_CLIENT_SECRET" \
  -d scope="openid profile metrics.read" | jq -r .access_token)

A healthy response is a JSON object holding access_token, token_type set to Bearer, and expires_in in seconds. HTTP 400 with "error": "invalid_client" means the client ID or the secret does not match, or the provider is set to Public instead of Confidential.

The access token is a signed JWT (JSON web token). Read what is inside it:

python3 -c 'import base64,json,sys; p=sys.argv[1].split(".")[1]; print(json.dumps(json.loads(base64.urlsafe_b64decode(p+"="*(-len(p)%4))),indent=2))' "$TOKEN"

The claims that do real work are iss (built from the application slug), aud (the client ID of this provider), sub (the service account, stable for this agent), exp, and scope. Copy iss and aud somewhere you can paste from. The proxy configuration further down has to match both strings exactly, and guessing them instead of reading them is the usual way to lose an afternoon.

One authentik behaviour will catch you out: a client that requests no scopes is treated as though it had requested every scope configured on the provider. Drop the scope= parameter and the token comes back holding all of them. Always send the list you actually want.

Scope the token to the one thing the agent does

A scope is a name inside the token that a service can check. authentik ships openid, profile, email, offline_access and entitlements. None of those describe your internal service, so define one.

Open Customization > Property Mappings > Create and choose Scope Mapping. Set the scope name to metrics.read. The expression only adds claims to the token, so when the scope name itself is the whole grant, return nothing:

return {}

Then open the provider, go to Advanced protocol settings, and select metrics.read under Scopes. Verify it by requesting a fresh token and decoding it again. If metrics.read is missing from the scope claim, the mapping is not selected on the provider and nothing downstream can act on it.

Here is the part people skip: the scope enforces nothing by itself. It is a string in a signed document. Something has to read that string and refuse the request, either your service or the proxy in front of it. A service that accepts any valid token is exactly as open as it was yesterday, and now it just has better logs.

Keep the granularity honest. One client per agent, per job. If the same agent both reads metrics and restarts containers, that is two providers and two secrets, so you can revoke the restart credential at 03:00 and leave the monitoring working.

Keep the token short lived, and rotate the secret separately

In the provider, under Advanced protocol settings, set Access Token validity. The field takes a duration string with semicolons between the parts and keys such as minutes, hours and days, so minutes=10 gives a ten minute token. The client credentials grant issues no refresh token, so the agent simply asks again. Cache the token in memory and fetch a new one shortly before exp.

Short lifetimes matter because of what revocation cannot do. A service that validates the signature offline never asks authentik whether the token is still good, so an issued token keeps working until exp no matter what you disable in the admin interface. That validity setting is the real size of your worst case.

The client secret is the long lived thing, so the secret is what you protect. It belongs in a secret store or a systemd credential, never in a prompt, a repository, or a file the agent can read and repeat back to whoever asks nicely. Keeping credentials out of the model's context window covers the ways an agent leaks one and what to hand it instead. Rotating that secret is one button on one provider, and it touches nothing else you run.

Check the token at the proxy, not on the network

Most internal services trust the network. A request that arrives on the private interface is allowed, because historically only trusted things were on that interface. An agent breaks the assumption quietly: it runs on the same private network and inherits the same trust. Move the check to the proxy and the network stops being the credential.

oauth2-proxy can hold both doors at once, interactive login for humans in a browser and bearer tokens for machines. Two flags carry the machine half:

oauth2-proxy \
  --provider=oidc \
  --oidc-issuer-url=https://sso.example.com/application/o/internal-metrics/ \
  --client-id="$PROXY_CLIENT_ID" \
  --client-secret="$PROXY_CLIENT_SECRET" \
  --cookie-secret="$COOKIE_SECRET" \
  --email-domain='*' \
  --upstream=http://127.0.0.1:9090 \
  --http-address=0.0.0.0:4180 \
  --reverse-proxy=true \
  --skip-jwt-bearer-tokens=true \
  --extra-jwt-issuers='https://sso.example.com/application/o/deploy-agent/=DEPLOY_AGENT_CLIENT_ID'

--skip-jwt-bearer-tokens tells oauth2-proxy to accept a request carrying a verified JWT bearer token instead of sending it to the login page. The token must have an aud that matches oauth2-proxy's own client ID, or one of the pairs listed in --extra-jwt-issuers. Your agent has its own provider, so its aud is its own client ID and not the proxy's. Leave the pair out and the agent's request is handled as an anonymous browser and redirected to the sign in flow. An HTTP 302 arriving at a program that expected JSON is the classic symptom of this setup being nearly right.

Write the pair as issuer=audience, taking both values from the decoded token rather than from the admin interface. authentik's issuer mode decides whether iss carries the application slug, so two providers that look identical in the UI can produce different issuer strings.

Check it from outside:

curl -s -o /dev/null -w '%{http_code}\n' \
  -H "Authorization: Bearer $TOKEN" \
  'https://metrics.example.com/api/v1/query?query=up'

200 means the proxy validated the token and passed the request upstream. Now run the same command with the Authorization header removed. You should get 302. That second result is the one that matters, because it proves the proxy is deciding access rather than the network. The full proxy build, cookie secret and nginx side included, is in putting oauth2-proxy in front of an app that has no login of its own.

What the audit trail looks like now

Running oauth2-proxy with --upstream, it passes X-Forwarded-User, X-Forwarded-Email, X-Forwarded-Groups and X-Forwarded-Preferred-Username to the upstream by default, because --pass-user-headers defaults to true. In nginx auth_request mode you set --set-xauthrequest=true instead, which returns X-Auth-Request-User, X-Auth-Request-Email, X-Auth-Request-Groups and X-Auth-Request-Preferred-Username on the auth response for nginx to copy onto the upstream request.

Log that header in your application or in the nginx access log format, and every line names its caller. The agent's lines read ak-deploy-agent-client_credentials. Yours read your username. The question "who did this" becomes something you answer by reading rather than by remembering.

On the IdP side, open Events > Logs in authentik and filter by the service account name to see what authentik recorded for that agent. What the IdP knows is coarse by design: a token was issued, to this subject, at this time. It does not know what the agent then did with it. Turning "the agent called this endpoint" into "the agent did this, for this reason" needs instrumentation inside the agent itself, which is the separate problem of seeing what an agent actually did during a run.

Revoke one agent without locking yourself out

The cheapest revocation is rotating that provider's client secret. The agent's next token request fails with invalid_client, and nothing else on your stack notices. If you want the account gone rather than the credential changed, open Directory > Users, find ak-deploy-agent-client_credentials, and set it inactive. Deleting the provider and its application removes the registration entirely.

None of that kills a token already in flight. Check what is actually live with introspection:

curl -s -X POST https://sso.example.com/application/o/introspect/ \
  -d client_id="$AGENT_CLIENT_ID" \
  -d client_secret="$AGENT_CLIENT_SECRET" \
  -d token="$TOKEN" | jq .active

true means authentik still considers the token valid. Wait past the Access Token validity you configured and it returns false. A proxy validating signatures locally never makes this call, and that is the trade: local validation is fast and survives an IdP outage, and in exchange revocation waits for expiry. Ten minute tokens keep that wait short.

What this boundary does not cover

Identity constrains what the agent may reach. It does not constrain what the agent does with what it reached. A token scoped to metrics.read is a perfectly good token for reading every metric you have and folding them into a summary that leaves your network. Prompt injection needs no new permissions. A poisoned instruction inside a document the agent reads runs with exactly the scope you granted, no more and no less.

Identity is also not authorization. The IdP states who is calling. Something downstream still decides what that caller may do. If your internal service treats any signed token as full access, a dedicated agent identity has improved your logs and changed nothing about your exposure.

The control that covers actions rather than reach is an approval step: the agent proposes, a person confirms, the confirmation is recorded. Putting a human approval gate in front of the actions that matter sits beside this work rather than replacing it. Identity tells you which agent asked. The gate decides whether the answer is yes.

Where the cloud platforms are landing

Both large clouds now ship this as a product, which is useful mainly as evidence that the shape is right. As of August 2026, Amazon Bedrock AgentCore Identity gives each agent a workload identity with its own OAuth2 tokens for external services, and Google Cloud's Agent Identity issues each deployed agent a credential with its own SPIFFE ID, so access events are attributable to the agent instead of to whoever deployed it. Both are tied to their platform. Both describe the move you just made by hand: one identity per agent, issued centrally, scoped narrowly, revocable on its own.

Which IdP you run matters less than running one. The client credentials grant is part of OAuth 2.0, so the differences between authentik, Keycloak and Zitadel are in the admin experience rather than the protocol. If you have not chosen yet, the comparison of Keycloak, authentik and Zitadel covers what each one costs you in operational effort, and the authentik install and first application is the fastest route to a working IdP on a VPS.

FAQ

Why does my client credentials request return invalid_client?

The client ID or the client secret does not match the provider. First check for a trailing newline in the environment variable, which echo adds easily and which is invisible afterwards. Then check the provider's client type: the grant requires a Confidential client, because a Public client holds no secret to verify. Finally confirm you are posting to https://sso.example.com/application/o/token/, which is shared by all providers, rather than to a path containing your application slug.

Does the agent need a refresh token?

No. The client credentials grant returns an access token and no refresh token, because there is no user session to refresh. The agent holds the client secret, so it can request a new access token at any time. Cache the token in memory and renew it shortly before exp, rather than fetching a fresh one per request, which turns your IdP into a bottleneck.

The token is valid but oauth2-proxy still redirects to the login page. What is wrong?

oauth2-proxy accepts a bearer token only when --skip-jwt-bearer-tokens is set, and only when the token's aud matches its own client ID or one of the pairs in --extra-jwt-issuers. Decode the token, read iss and aud, and write the pair as iss=aud using those exact strings. A redirect means the token was never considered as a bearer token at all, which is a different failure from a token being rejected as invalid.

Can I skip this and give the agent a scoped API key instead?

For a single service that issues per key scopes and keeps its own audit log, a scoped key is a reasonable answer. It stops scaling at the second service, because you then hold one long lived secret per service, each with its own rotation procedure and its own place to be forgotten. One OIDC client gives you a single place to scope, rotate and revoke, plus tokens that expire without anyone remembering to expire them.