Skip to content

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 headerWith the header
The token acts as its ownerThe call acts as the person named in the signed statement
Usage and limits count the applicationUsage and limits count the person (limits on the token are shared by everyone it acts for)
Logs show the token ownerLogs show the person as the subject and the token as the actor

How it works ​

text
 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 project

Without 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).

  1. Open Settings → IAM center → Principals → API tokens (or the API Tokens page) and open the token your application uses.
  2. In the Acting for users section, select Set up.
  3. 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 ​

SettingMeaning
IssuerThe iss the statement must carry, compared exactly (at most 512 characters, no surrounding spaces).
AudienceThe aud it must name. One, or up to eight (one per line).
Signature algorithmsHS256, 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 ​

SettingMeaning
Find the personBy identity (issuer + subject) and/or by verified email address.
When nobody matchesNobody: 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 mapOptional: identity provider group => console group, one per line (at most 100). See Groups that follow the statement.
Allow administratorsOff 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).

ClaimMeaning
iss, aud, sub, iat, expRequired. sub identifies the person at the issuer (at most 512 characters).
email, email_verifiedThe address, and whether the issuer vouches for it (a boolean). Only a verified address can match or create a person.
nameDisplay name (up to 200 characters).
groupsStrings only (at most 100, each up to 200 characters).
ctxOptional 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 ​

js
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);
}
python
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:

bash
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 ​

  1. 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.
  2. 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.
  3. 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 keep via: { kind: "delegated", issuer } (audit rows, RAG query logs, agent runs and trace tags via / viaIssuer). The runtime context of an agent run carries userId = the person and tokenId = 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).

StatuscodeMeaning
401delegation_not_enabledThe header was sent but the token is not set up (or it is switched off).
401invalid_assertionMalformed, wrong algorithm, bad signature, wrong issuer or audience, missing or odd claims, several headers, an empty header.
401expiredPast exp (plus skew), or older than the maximum age.
401untrusted_keyNo trusted key could verify it: none configured for the algorithm, an unknown kid, an unreadable key, a key set that could not be fetched.
401unknown_principalNobody matches and the token does not create accounts (or a person needs a verified address).
401disabledThe person's account is disabled.
401seat_limitCreating the account would exceed the licence user limit.
401conflictThe address belongs to an account linked to another identity of the issuer, or to an existing account when a person would be created.
403delegation_refusedThe person is the workspace owner, or an administrator and Allow administrators is off.
403project_not_accessibleThe 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).
503delegation_unavailableThe stores could not answer. Retry; never treated as allowed. A Retry-After header is sent.
json
{
  "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-Token is deliberately not among the headers a browser is allowed to send across origins.
  • Prefer RS256 or ES256 with 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.

CallWhat it does
GET /api/tokens/{id}/delegationThe 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}/delegationCreate or replace the setup.
DELETE /api/tokens/{id}/delegationRemove it. Accounts already created stay. Answers { "deleted": true }.

PUT takes:

json
{
  "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
}
  • matchBy takes externalId (by identity) and/or verifiedEmail; jit is none, person or external.
  • A key id without a secret keeps the stored one, so a screen never has to hold a secret to change another setting.
  • For RS256/ES256 use keys: { "jwksUrl": "https://…", "pem": "-----BEGIN PUBLIC KEY-----…" } instead of hsSecrets.
  • A user token is sent with many calls, so trust.requireSingleUse cannot be true. Unknown settings are refused.
  • Errors: 403 forbidden (not an owner or admin), 403 higher_privileges (never upward), 404 token_not_found, 400 invalid_body with a problems list.

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.

Studio · Pulse · Console · Agent SDK and more — the Cognipeer documentation hub