Acting for a user
An application that signs its own users in can let them use the Console client API as themselves instead of as the application. The application keeps calling with its API token and adds a signed statement about the person the call is for, in the X-Cognipeer-User-Token header. Console checks the statement, finds the person's account and runs the call as that person: their access, their limits and their name in the logs. The token stays the actor, and its scope and project still cap the person.
Requires a Console release with the IAM engine
Acting for a user is set up per API token in the IAM center (Settings → IAM center → Principals → API tokens) and is decided by the IAM engine. The setup screen, the /api/tokens/{id}/delegation routes and the X-Cognipeer-User-Token header described here exist only in releases that include the engine.
When to use it
Use it when your application authenticates its own users and you want Console to know who is calling, for example a customer-support product that lets each agent of your customers use their own Console assistants, quotas and history. Without it, every call made with the application's token looks like the same account: one set of limits, one name in the logs, and one level of access for everybody.
| Without the header | With the header |
|---|---|
| The token acts as its owner | The call acts as the person named in the signed statement |
| Usage and limits count the application | Usage and limits count the person (limits on the token are shared by everyone it acts for) |
| Logs show the token owner | Logs show the person as the subject and the token as the actor |
How it works
application backend console
─────────────────── ───────
Authorization: Bearer <app token> → 1. the token is authenticated as always
X-Cognipeer-User-Token: <signed jwt> → 2. the statement is verified (issuer, audience, signature, expiry)
3. the person is found (or created)
4. the call runs as the person, capped by the token's scope and projectWithout the header nothing changes: the token acts as its owner. With the header the owner is never the fallback: if the statement is not accepted the call is refused, so an application that forgot to configure something fails loudly instead of acting with the wrong access.
Set it up
You need the owner or admin role, and the usual "never upward" rule applies: an admin cannot set it up on the owner's token. Setting it up lets the token act as other accounts, so it is an access-rule change and is written to the audit trail with what it was and what it became (never a key or a secret).
- Open Settings → IAM center → Principals → API tokens (or the API Tokens page) and open the token your application uses.
- In the Acting for users section, select Set up.
- Fill in the trust settings and the identity mapping, described below, then Save.
Plan the token first:
- Give the token a scope (the screen warns when it has none: This token has no scope of its own, so the people it acts for are limited only by their own access).
- Bind it to a project, and give the people (or their delegated groups) access to that project. A person who cannot open it is refused.
Trust: who may vouch, and how
| Setting | Meaning |
|---|---|
| Issuer | The iss the statement must carry, compared exactly (at most 512 characters, no surrounding spaces). |
| Audience | The aud it must name. One, or up to eight (one per line). |
| Signature algorithms | HS256, RS256 or ES256, pinned: a statement signed with anything else is refused, and none is never accepted. The key material must belong to the pinned algorithms. |
Shared secrets (HS256) | One or more secrets of at least 32 characters (up to eight), each with a key id (kid: letters, digits and . _ : -, at most 64 characters) so you can rotate. A secret is sealed at rest and never shown again. |
Key set URL (JWKS) (RS256/ES256) | https only (plain http is accepted only for an address inside your network, such as localhost, .internal, .local or a private address). Fetched through the outbound network guard, at most 256 KB, cached for ten minutes, and read again at most every 30 seconds when a statement names an unknown kid. |
Public key (PEM) (RS256/ES256) | A public key or certificate, never a private key. It is used before the key set. |
| Longest token age (seconds, optional) | A statement issued for longer than this (exp - iat), or issued longer ago than this, is refused. A whole number from 1 to 86,400. |
| Clock skew (seconds, optional) | How far apart the two clocks may be. A whole number from 0 to 300; default 60. |
Console refuses to save a configuration where a pinned algorithm has no key, or a key has no pinned algorithm that can use it. A statement for an API call is reused across calls until it expires, so it is not single-use here; keep its lifetime short and set a maximum age.
Identity mapping: who the person is here
| Setting | Meaning |
|---|---|
| Find the person | By identity (issuer + subject) and/or by verified email address. |
| When nobody matches | Nobody: the person must already have an account (refused otherwise). An external account: a person of a host application, see External users. A person managed by the identity provider: an ordinary account with no password; the statement must carry a verified email nobody else uses. |
| Group map | Optional: identity provider group => console group, one per line (at most 100). See Groups that follow the statement. |
| Allow administrators | Off by default: a call for a workspace administrator is refused. The workspace owner can never be acted for. |
The statement
A compact JWT. Bearer in front of it is accepted. It must be at most 16 KB, in a single header (two X-Cognipeer-User-Token headers are refused as ambiguous).
| Claim | Meaning |
|---|---|
iss, aud, sub, iat, exp | Required. sub identifies the person at the issuer (at most 512 characters). |
email, email_verified | The address, and whether the issuer vouches for it (a boolean). Only a verified address can match or create a person. |
name | Display name (up to 200 characters). |
groups | Strings only (at most 100, each up to 200 characters). |
ctx | Optional context of the application (at most 4 KB). It is signed but is data Console does not vouch for. |
Example: mint the statement on your backend
import { SignJWT } from 'jose';
const secret = new TextEncoder().encode(process.env.CONSOLE_USER_TOKEN_SECRET); // >= 32 chars
export function userTokenFor(user) {
return new SignJWT({
email: user.email,
email_verified: true,
name: user.name,
groups: user.groups, // e.g. ["support", "tier-2"]
})
.setProtectedHeader({ alg: 'HS256', kid: 'k1' })
.setIssuer('https://app.example.com')
.setAudience('cognipeer-console')
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime('5m')
.sign(secret);
}import os, time, jwt
SECRET = os.environ["CONSOLE_USER_TOKEN_SECRET"] # >= 32 chars
def user_token_for(user):
now = int(time.time())
return jwt.encode(
{
"iss": "https://app.example.com",
"aud": "cognipeer-console",
"sub": user["id"],
"iat": now,
"exp": now + 300,
"email": user["email"],
"email_verified": True,
"name": user["name"],
"groups": user["groups"],
},
SECRET,
algorithm="HS256",
headers={"kid": "k1"},
)Then call the client API with both headers:
curl -X POST https://console.example.com/api/client/v1/agents/responses \
-H "Authorization: Bearer $APP_TOKEN" \
-H "X-Cognipeer-User-Token: $USER_JWT" \
-H "Content-Type: application/json" \
-d '{ "model": "support-bot", "input": "Where is my refund?" }'Finding the person
- By identity: the account that holds this issuer's subject. The identity is qualified by its issuer, so two issuers can never be mistaken for each other.
- By verified email, members only: an account with that address, when the issuer says the address is verified. With By identity also enabled this links the account to the subject, so the next call needs no address and the address cannot be taken over later. An account already linked to another subject of the issuer is a conflict (
401 conflict). An external account is never found by address, and a service account is not a person an issuer can vouch for. - Nobody: create one as configured, or refuse.
A member's own profile (name, email) is never rewritten from a statement; an external account's profile follows the statement. A disabled account is refused. Creating an account counts toward the licence user limit (seat_limit), whichever kind.
Finding an account by identity scans the workspace's accounts (there is no index), so each node remembers which account an identity resolved to (the account itself is read again on every call) and, for 30 seconds, that nobody holds an identity: an account made for the person elsewhere is recognized within that time.
What the person gets
- Access. The engine decides with the person as principal: their role, own permissions, groups and project levels, then the token's scope and project as caps. A custom policy about the person (including one written through the visual editor, which also matches the tokens that act as them) applies. A policy about the token's owner (typically a service account) does not apply to the people the token acts for; a policy about the token itself still does.
- Limits. Usage limits and quotas for a person count the person, not the application and not their colleagues. Limits on the token are shared by everyone it acts for.
- The project. The call runs in the project the token is bound to, and the person must be able to open that project themselves, exactly as they would in the dashboard: a project they are a member of, a project one of their groups is assigned to (for an external person, a delegated group an administrator assigned to it), or any project for a person whose own role is administrator. Otherwise the call is refused with
403 project_not_accessible; the person is never moved to another project they can open. The check is made on every call, so taking a membership or a group's project away applies at once on this node (other nodes within about 15 seconds). - Owners and administrators. The workspace owner can never be acted for. An administrator (also one through a group that grants the role) can be acted for only when Allow administrators is on. The rule is applied before anything is written for the account: a refused owner or administrator is not linked to the statement's subject, and their groups are left alone.
- Logs and traces. Usage rows, request logs, runs and traces record the person as the subject (
userId) and the application token as the actor (apiTokenId). Records that have a metadata field also keepvia: { kind: "delegated", issuer }(audit rows, RAG query logs, agent runs and trace tagsvia/viaIssuer). The runtime context of an agent run carriesuserId= the person andtokenId= the application token. The person's drawer in the IAM center has an Activity section: usage by service (with the tokens it came through) and the audit rows they are the subject of.
A refusal does not undo what the statement already did: an account that was created or linked stays, with the groups the claim names. Assigning the project to one of those groups lets the next call in.
Refusals
Every refusal carries a decision id (in the body, in the X-Decision-Id header and in the audit row), in the JSON, OpenAI and Anthropic dialects (error.code, error.decision_id).
| Status | code | Meaning |
|---|---|---|
| 401 | delegation_not_enabled | The header was sent but the token is not set up (or it is switched off). |
| 401 | invalid_assertion | Malformed, wrong algorithm, bad signature, wrong issuer or audience, missing or odd claims, several headers, an empty header. |
| 401 | expired | Past exp (plus skew), or older than the maximum age. |
| 401 | untrusted_key | No trusted key could verify it: none configured for the algorithm, an unknown kid, an unreadable key, a key set that could not be fetched. |
| 401 | unknown_principal | Nobody matches and the token does not create accounts (or a person needs a verified address). |
| 401 | disabled | The person's account is disabled. |
| 401 | seat_limit | Creating the account would exceed the licence user limit. |
| 401 | conflict | The address belongs to an account linked to another identity of the issuer, or to an existing account when a person would be created. |
| 403 | delegation_refused | The person is the workspace owner, or an administrator and Allow administrators is off. |
| 403 | project_not_accessible | The person cannot open the project the token is bound to: no membership of their own or of a group they are in (an external person needs a delegated group that was given the project). |
| 503 | delegation_unavailable | The stores could not answer. Retry; never treated as allowed. A Retry-After header is sent. |
{
"error": "This API token is not set up to act for users",
"code": "delegation_not_enabled",
"decisionId": "3b9c0d1e-7a2f-4e55-9c1b-6f2a8d4e0b17"
}The audit row of a refused call is attributed to the application token alone (never to the account the token belongs to) and carries metadata.code: "delegation_refused" with the typed reason (project_not_accessible included) in metadata.reasons.
An endpoint that authenticates on its own and does not act for users refuses a request that carries the header with 400 and code: "user_token_not_supported", rather than answer as the token's owner.
Recommendations
- Send the statement from your application's backend, together with the application token, and never from a browser: the token is a secret, and
X-Cognipeer-User-Tokenis deliberately not among the headers a browser is allowed to send across origins. - Prefer
RS256orES256with a key set over a shared secret: Console then holds no secret that can mint statements. - Give the token a scope: the people it acts for are then capped by it. Bind it to a project, and give the people (or their delegated groups) access to that project.
- Set a maximum token age and keep statements short-lived.
- Leave Allow administrators off.
- Remember that matching by verified email trusts the issuer's
email_verified. Turn it off if the issuer does not verify addresses.
API
The setup is part of the dashboard API (session cookie, not an API token). Owners and admins only, never upward.
| Call | What it does |
|---|---|
GET /api/tokens/{id}/delegation | The setup, or null. Shared secrets are never returned: only their key ids. The response also says whether the token has a scope (tokenScoped). |
PUT /api/tokens/{id}/delegation | Create or replace the setup. |
DELETE /api/tokens/{id}/delegation | Remove it. Accounts already created stay. Answers { "deleted": true }. |
PUT takes:
{
"enabled": true,
"trust": {
"issuer": "https://app.example.com",
"audience": "cognipeer-console",
"algorithms": ["HS256"],
"keys": { "hsSecrets": [{ "kid": "k1", "secret": "<at least 32 characters>" }] },
"maxTokenAgeSeconds": 600,
"clockSkewSeconds": 60
},
"mapping": {
"matchBy": ["externalId", "verifiedEmail"],
"jit": "external",
"groupMap": [{ "idpGroup": "support", "consoleGroupName": "Support (app)" }]
},
"allowPrivileged": false
}matchBytakesexternalId(by identity) and/orverifiedEmail;jitisnone,personorexternal.- A key id without a
secretkeeps the stored one, so a screen never has to hold a secret to change another setting. - For
RS256/ES256usekeys: { "jwksUrl": "https://…", "pem": "-----BEGIN PUBLIC KEY-----…" }instead ofhsSecrets. - A user token is sent with many calls, so
trust.requireSingleUsecannot betrue. Unknown settings are refused. - Errors:
403 forbidden(not an owner or admin),403 higher_privileges(never upward),404 token_not_found,400 invalid_bodywith aproblemslist.
Troubleshooting
delegation_not_enabled on every call. The token has no setup, or it is switched off. Open Acting for users on the token and check Acting for users is on. Remember a call that carries the header is never answered as the token's owner.
invalid_assertion with a good-looking token. Check, in order: the alg header is one of the pinned algorithms; iss equals the issuer exactly (no trailing slash difference); aud names a configured audience; iat and exp are present and iat is not in the future; there is only one X-Cognipeer-User-Token header; the value is a three-part compact JWT of at most 16 KB.
untrusted_key. A kid that names no configured secret, an HS256 token with several secrets but no kid header, or a key set that could not be fetched. For HS256 always send a kid when you have more than one secret.
expired right after minting. Check the clocks of your backend and Console (skew allows 60 seconds by default), and whether Longest token age is shorter than the statement's lifetime (exp - iat must not exceed it).
unknown_principal. Nobody matches and When nobody matches is Nobody, or the statement carries no verified email for a Person account. Choose An external account, or create the account first.
project_not_accessible. The person cannot open the project the token is bound to. Add them to the project, or add a group they are in (for an external person, a delegated group) to the project's members.
delegation_refused for an admin. Allow administrators is off. The workspace owner is always refused.
Use IAM center → Access check → Look up a denied request with the decision id to see the audit row.

