---
title: Act on Behalf of Absent Users | Keycard
description: 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](/concepts/credentials/#impersonation/index.md): the agent authenticates as *itself*, names the *user* it acts for, and exchanges the two for a credential.

Is the user present?

This guide is for the case where the user is **not** present — scheduled jobs, queue workers, long-running agents. If the user is in the loop and you have their token on the request, use [Access APIs on Behalf of Users](/guides/access-apis-on-behalf-of-users/index.md) (CLI) or [Call External APIs from MCP](/guides/delegated-access/index.md) (SDK) instead, which forward the live user token.

One-Time ConsentUser authorizes via a landing page, once

Background AgentRuns non-interactively, no user present

Per-Request TokensScoped, short-lived, never stored

Audit TrailActor and delegator both logged

## 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](https://console.keycard.ai). See [Call External APIs from MCP](/guides/delegated-access/#keycard-setup/index.md) 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

1. **Install the SDK**

   - [Python](#tab-panel-66)
   - [TypeScript](#tab-panel-67)
   - [Go](#tab-panel-68)

   Terminal window

   ```
   pip install keycardai-oauth
   ```

   Terminal window

   ```
   npm install @keycardai/oauth
   ```

   Terminal window

   ```
   go get github.com/keycardai/credentials-go/oauth
   ```

2. **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 URI `http://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](/concepts/applications/#configuration/index.md) to `implicit` — no user is present to approve a consent screen during the exchange. |
   | **Restriction policy** *(optional)* | A [forbid policy](/admin/access-policies/index.md) 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-agent` so it may impersonate only one user (Cedar `forbid` overrides 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_ID` and `KEYCARD_CLIENT_SECRET`. Never commit them.

3. **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.

   - [Python](#tab-panel-69)
   - [TypeScript](#tab-panel-70)
   - [Go](#tab-panel-71)

   ```
   # landing_page.py — the two calls that matter
   from keycardai.oauth import Client, build_authorize_url
   from keycardai.oauth.http.auth import NoneAuth
   from keycardai.oauth.types.models import ClientConfig
   from keycardai.oauth.utils.pkce import PKCEGenerator


   client = Client(
       base_url="<keycard-issuer-url>",  # from Settings → Connection
       auth=NoneAuth(),  # public client, PKCE only
       config=ClientConfig(enable_metadata_discovery=True, auto_register_client=False),
   )


   # On GET /authorize: redirect the user to Keycard
   pkce = 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 authorized
       scope="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 matter
   import {
     fetchAuthorizationServerMetadata,
     generatePkcePair,
     buildAuthorizeUrl,
     exchangeAuthorizationCode,
   } from "@keycardai/oauth";


   const issuer = "<keycard-issuer-url>"; // from Settings → Connection
   const metadata = await fetchAuthorizationServerMetadata(issuer);


   // On GET /authorize: redirect the user to Keycard
   const 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 matter
   import "github.com/keycardai/credentials-go/oauth"


   issuer := "<keycard-issuer-url>" // from Settings → Connection
   metadata, err := oauth.FetchAuthorizationServerMetadata(ctx, issuer)


   // On GET /authorize: redirect the user to Keycard
   pkce, 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>",
   })
   ```

   Tip

   A complete, runnable landing page (HTML, PKCE store, callback handling) ships as the [`impersonation_token_exchange` example](https://github.com/keycardai/python-sdk/tree/main/packages/oauth/examples/impersonation_token_exchange) in the Python SDK. Any standard OAuth 2.1 PKCE web flow works.

4. **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.

5. **Get the user’s identifier from Keycard Console**

   In Keycard Console, find the user under **Users** and copy their [identifier](/concepts/users/#identifiers/index.md) (often an email or `sub`, depending on how the Provider maps claims). This is the value the agent names when it impersonates.

6. **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](https://datatracker.ietf.org/doc/html/rfc8693) token exchange for you.

   - [Python](#tab-panel-72)
   - [TypeScript](#tab-panel-73)
   - [Go](#tab-panel-74)

   ```
   import os
   from keycardai.oauth import Client
   from keycardai.oauth.http.auth import BasicAuth
   from keycardai.oauth.types.models import ClientConfig


   with Client(
       base_url="<keycard-issuer-url>",  # from Settings → Connection
       auth=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 user
   ```

   ```
   import { TokenExchangeClient } from "@keycardai/oauth";


   const client = new TokenExchangeClient("<keycard-issuer-url>", { // from Settings → Connection
     clientId: 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 user
   ```

   ```
   import (
       "context"
       "log"
       "os"


       "github.com/keycardai/credentials-go/oauth"
   )


   client := oauth.NewTokenExchangeClient(
       "<keycard-issuer-url>", // from Settings → Connection
       oauth.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
   ```

7. **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/user
   ```

   Mint a fresh token per request — they are short-lived and meant to be thrown away, not cached across jobs.

8. **Verify in Keycard Console**

   Open [Keycard Console](https://console.keycard.ai) → **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:authorize`   | The one-time grant from the landing page         |
   | `credentials:issue` | Each token the agent minted on the user’s behalf |

## 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

This guide uses a client secret to keep the walkthrough short, but a shared secret is the weakest of the [Application credentials](/concepts/applications/#credentials/index.md) — 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](/concepts/applications/#workload-identity/index.md) token instead of a secret. [Run Apps Without Static Secrets](/guides/run-apps-without-static-secrets/index.md) 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](/concepts/applications/#url/index.md). No shared secret lives anywhere. [Grant Agent Access to APIs](/guides/grant-agent-access-to-apis/index.md) shows an agent bootstrapping this identity with the SDK’s `WebIdentity` helper.

## What’s next

- **[Credential Issuance](/concepts/credentials/#impersonation/index.md)**: the full impersonation model and how it relates to delegation and autonomous access.
- **[Grant Agent Access to APIs](/guides/grant-agent-access-to-apis/index.md)**: build an agent that acts as *itself* rather than as a user.

## 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.identifier` matches 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_SECRET` are 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](/concepts/users/#identifiers/index.md) from Keycard Console → **Users**.
- The identifier can be changed in Console; make sure your code uses the current value.
