Act on Behalf of Absent Users
Build a background agent that calls APIs as a specific user who isn't present, using short-lived per-request tokens and the Keycard SDK.
Build a background agent that acts as a specific user without that user being present. The user grants access once through a landing page. After that, the agent mints a short-lived token scoped to that user on every request, with no browser, no session, and nothing stored. This is impersonation: the agent authenticates as itself, names the user it acts for, and exchanges the two for a credential.
Prerequisites
Section titled “Prerequisites”- Keycard. You need your Keycard Issuer URL (found under Settings → Connection, e.g.
<keycard-issuer-url>). - A Provider and Resource. The Resource you want the agent to reach (e.g. a GitHub-backed Resource at
https://api.github.com), linked to its OAuth Provider in Keycard Console. See Call External APIs from MCP for adding an API from the catalog. - Two Applications (created in the walkthrough): a public landing page for the one-time consent, and a confidential background agent that does the impersonation.
- Python 3.10+, Node.js 18+, or Go 1.22+, depending on which SDK you use.
Walkthrough
Section titled “Walkthrough”-
Install the SDK
Terminal window pip install keycardai-oauthTerminal window npm install @keycardai/oauthTerminal window go get github.com/keycardai/credentials-go/oauth -
Provision the two Applications in Keycard Console
You set up two Applications. With the default policies, the agent can impersonate a user for any Resource it declares as a dependency once that user has delegated it — no policy is needed to enable impersonation. Add a policy only to restrict it.
Step What to create Landing page app A public credential (identifier e.g. landing-page), redirect URIhttp://localhost:3000/callback. Add the Resource as a dependency.Background agent app A confidential credential (identifier e.g. background-agent) with a client secret. Add the same Resource as a dependency, and set its consent toimplicit— no user is present to approve a consent screen during the exchange.Restriction policy (optional) A forbid policy if you want to limit impersonation — e.g. allow it only for specific users. Without one, the agent can impersonate any user who has delegated a Resource it depends on. To restrict
background-agentso it may impersonate only one user (Cedarforbidoverrides the default permit):forbid (principal is Keycard::Application,action,resource)when {principal.identifier == "background-agent" &&context has impersonate && context.impersonate == true}unless {context has subject &&context.subject.identifier == "troy.barnes@greendale.edu"};Put the agent’s client ID and secret in the environment as
KEYCARD_CLIENT_IDandKEYCARD_CLIENT_SECRET. Never commit them. -
Build the landing page for one-time consent
The landing page runs the standard authorization-code-with-PKCE flow. The user signs in once and authorizes the Resources configured as the app’s dependencies. That establishes the consent grant the agent relies on later — there is no token to keep here.
# landing_page.py — the two calls that matterfrom keycardai.oauth import Client, build_authorize_urlfrom keycardai.oauth.http.auth import NoneAuthfrom keycardai.oauth.types.models import ClientConfigfrom keycardai.oauth.utils.pkce import PKCEGeneratorclient = Client(base_url="<keycard-issuer-url>", # from Settings → Connectionauth=NoneAuth(), # public client, PKCE onlyconfig=ClientConfig(enable_metadata_discovery=True, auto_register_client=False),)# On GET /authorize: redirect the user to Keycardpkce = PKCEGenerator().generate_pkce_pair()url = build_authorize_url(client.endpoints.authorize,client_id="<landing-page-client-id>",redirect_uri="http://localhost:3000/callback",pkce=pkce,resources=[], # dependencies determine what gets authorizedscope="openid email",state=state, # store state -> pkce.code_verifier)# On GET /callback: trade the code for tokens (this records the grant)token_response = client.exchange_authorization_code(code=code,redirect_uri="http://localhost:3000/callback",code_verifier=code_verifier,client_id="<landing-page-client-id>",)// landing-page.ts — the two calls that matterimport {fetchAuthorizationServerMetadata,generatePkcePair,buildAuthorizeUrl,exchangeAuthorizationCode,} from "@keycardai/oauth";const issuer = "<keycard-issuer-url>"; // from Settings → Connectionconst metadata = await fetchAuthorizationServerMetadata(issuer);// On GET /authorize: redirect the user to Keycardconst pkce = await generatePkcePair();const url = buildAuthorizeUrl(metadata.authorization_endpoint!, {clientId: "<landing-page-client-id>",redirectUri: "http://localhost:3000/callback",codeChallenge: pkce.codeChallenge,scope: "openid email",state, // store state -> pkce.codeVerifier});// On GET /callback: trade the code for tokens (this records the grant)const tokenResponse = await exchangeAuthorizationCode(issuer, code, {codeVerifier,redirectUri: "http://localhost:3000/callback",clientId: "<landing-page-client-id>",});// landing_page.go — the two calls that matterimport "github.com/keycardai/credentials-go/oauth"issuer := "<keycard-issuer-url>" // from Settings → Connectionmetadata, err := oauth.FetchAuthorizationServerMetadata(ctx, issuer)// On GET /authorize: redirect the user to Keycardpkce, err := oauth.GeneratePKCEPair()url, err := oauth.BuildAuthorizeURL(metadata.AuthorizationEndpoint, oauth.AuthorizeURLParams{ClientID: "<landing-page-client-id>",RedirectURI: "http://localhost:3000/callback",CodeChallenge: pkce.CodeChallenge,Scopes: []string{"openid", "email"},State: state, // store state -> pkce.CodeVerifier})// On GET /callback: trade the code for tokens (this records the grant)tokenResponse, err := oauth.ExchangeAuthorizationCode(ctx, issuer, oauth.AuthorizationCodeExchangeRequest{Code: code,CodeVerifier: codeVerifier,RedirectURI: "http://localhost:3000/callback",ClientID: "<landing-page-client-id>",}) -
Have the user authorize, once
Open the landing page, sign in, and approve access. You may see a consent screen from the Provider that establishes trust between Keycard and the upstream API. After this, the user can close the tab — the agent never needs them again unless a new Resource has to be authorized.
-
Get the user’s identifier from Keycard Console
In Keycard Console, find the user under Users and copy their identifier (often an email or
sub, depending on how the Provider maps claims). This is the value the agent names when it impersonates. -
Impersonate the user from the background agent
The agent authenticates with its own confidential credential and calls
impersonate()with the user identifier and target Resource. The SDK builds the substitute-user token and runs the RFC 8693 token exchange for you.import osfrom keycardai.oauth import Clientfrom keycardai.oauth.http.auth import BasicAuthfrom keycardai.oauth.types.models import ClientConfigwith Client(base_url="<keycard-issuer-url>", # from Settings → Connectionauth=BasicAuth(os.environ["KEYCARD_CLIENT_ID"],os.environ["KEYCARD_CLIENT_SECRET"],),config=ClientConfig(enable_metadata_discovery=True, auto_register_client=False),) as client:response = client.impersonate(user_identifier="troy.barnes@greendale.edu",resource="https://api.github.com",)token = response.access_token # short-lived, scoped to the userimport { TokenExchangeClient } from "@keycardai/oauth";const client = new TokenExchangeClient("<keycard-issuer-url>", { // from Settings → ConnectionclientId: process.env.KEYCARD_CLIENT_ID!,clientSecret: process.env.KEYCARD_CLIENT_SECRET!,});const response = await client.impersonate({userIdentifier: "troy.barnes@greendale.edu",resource: "https://api.github.com",});const token = response.accessToken; // short-lived, scoped to the userimport ("context""log""os""github.com/keycardai/credentials-go/oauth")client := oauth.NewTokenExchangeClient("<keycard-issuer-url>", // from Settings → Connectionoauth.WithClientCredentials(os.Getenv("KEYCARD_CLIENT_ID"),os.Getenv("KEYCARD_CLIENT_SECRET"),),)response, err := client.Impersonate(context.Background(), oauth.ImpersonateRequest{UserIdentifier: "troy.barnes@greendale.edu",Resource: "https://api.github.com",})if err != nil {log.Fatal(err)}token := response.AccessToken // short-lived, scoped to the user -
Call the upstream API with the token
The credential acts as the user against the Resource — for the brokered GitHub Resource here, that’s GitHub’s own token for that user. Use it like any bearer token:
Terminal window curl -H "Authorization: Bearer $TOKEN" https://api.github.com/userMint a fresh token per request — they are short-lived and meant to be thrown away, not cached across jobs.
-
Verify in Keycard Console
Open Keycard Console → Audit Log. The impersonated issuance is recorded with the Application as the actor and the impersonated user as its delegator, so access traces back to both:
users:authorizeThe one-time grant from the landing page credentials:issueEach token the agent minted on the user’s behalf
Errors
Section titled “Errors”| Error | Cause |
|---|---|
invalid_client | The caller is a public client or failed client authentication. |
invalid_request | The substitute-user token is malformed — it is signed (it must be unsigned), has the wrong typ or alg, or is missing sub. The SDK builds it correctly, so this usually means a hand-rolled request. |
invalid_grant | The sub identifier does not resolve to a known user. |
invalid_target | The resource is not a known, configured Resource. |
interaction_required | No prior delegation exists for the Resource; the user must authorize through the landing page first. |
access_denied | Access policy denied the exchange — the Resource is not one of the agent’s dependencies, or a policy forbids impersonation. |
Authenticate the agent without a static secret
Section titled “Authenticate the agent without a static secret”This guide uses a client secret to keep the walkthrough short, but a shared secret is the weakest of the Application credentials — and impersonation only requires a confidential client, not a secret specifically. Swap it for a credential that nothing has to store; the impersonate() call stays the same.
- Workload Identity (WI) — if the agent runs on AWS, Google Cloud, Vercel, or similar, it authenticates with the platform’s workload identity token instead of a secret. Run Apps Without Static Secrets walks through deploying an app this way.
- URL credential (CIMD) — the agent proves control of a URL with a signed, key-based assertion, and Keycard fetches its public key from the agent’s OAuth Client ID Metadata Document. No shared secret lives anywhere. Grant Agent Access to APIs shows an agent bootstrapping this identity with the SDK’s
WebIdentityhelper.
What’s next
Section titled “What’s next”- Credential Issuance: the full impersonation model and how it relates to delegation and autonomous access.
- Grant Agent Access to APIs: build an agent that acts as itself rather than as a user.
Troubleshooting
Section titled “Troubleshooting”The exchange returns access_denied
Access policy denied the exchange. With the default policies this happens when the Resource is not one of the agent Application’s dependencies, or when a policy explicitly forbids impersonation.
- Confirm the Resource is listed as a dependency of the agent Application.
- If you’ve added a restriction policy, confirm it isn’t forbidding this Application or subject — and that its
principal.identifiermatches the agent’s identifier exactly.
The exchange returns interaction_required
No prior grant exists for the user and Resource.
- The user must complete the landing-page flow for that Resource at least once.
- Adding a new Resource dependency requires the user to authorize again — the existing grant doesn’t cover it.
The exchange returns invalid_client
The agent failed client authentication, or you’re using a public client.
- Impersonation requires a confidential client. Verify
KEYCARD_CLIENT_ID/KEYCARD_CLIENT_SECRETare set and correct. - Confirm the agent Application has a client secret (or workload identity / URL credential), not a public credential.
The exchange returns invalid_grant
The user identifier doesn’t resolve to a known user.
- Copy the exact identifier from Keycard Console → Users.
- The identifier can be changed in Console; make sure your code uses the current value.