Console identity for MCP servers and connected agents
When an agent calls an MCP server or a connected agent, that server often needs to know who the call is for: a CRM that shows each salesperson only their own customers, a ticketing system that records who opened a ticket. With Console identity, Console tells it. Every call carries a short-lived token that Console signs for the person behind the call, valid for that one server only. The server checks it against the workspace's public keys and finds the person on its side.
Console does not forward the person's own sign-in token. A token issued for Console (for example by Google when the person signed in with SSO) is meant for Console only; handing it to another server is what the MCP specification forbids, and that server could not verify it anyway. Console issues a token of its own instead, which states who the person is and is addressed to that server.
When to use it
| A stored credential (Bearer token, header, basic auth) | Console identity |
|---|---|
| The server sees one caller: whoever owns the key | The server sees the person behind each call |
| A secret is stored in Console and must be rotated | Nothing secret is stored; tokens expire after five minutes |
| The server cannot tell two people apart | The server applies its own permissions per person |
Use it when the server has users of its own and should apply their access. Keep a stored credential for servers that answer every caller the same way.
How it works
person ──► agent (portal, API, playground …) your server (MCP / connected agent)
│ │
│ 1. Console signs a token for the person, │
│ for this server's URL, valid 5 minutes │
│ │
└── Authorization: Bearer <token> ────────────────────►│ 2. verifies it with the key set
│ <issuer>/.well-known/jwks.json
│ 3. finds the person (sub / email / idp_sub)
│ and applies its own permissionsThere is no token exchange and nothing to call back: the server only reads the public key set, and caches it.
Turn it on
| Where | What to choose |
|---|---|
| MCP server: create screen or Settings tab, section Upstream authentication | Console identity. Available for remote MCP (HTTP / SSE) and OpenAPI servers. |
| Connected agent: Connect agent or Edit connection, section Authentication | Method → Console identity. All three protocols (A2A, OpenAI Chat, OpenAI Responses). |
| API | MCP server: "upstreamAuth": { "type": "console_identity" } on POST/PATCH of the server. Connected agent: "config": { "kind": "external", "connection": { …, "authType": "console_identity" } }. |
Choosing Console identity replaces a stored key: the key, header or password the server had is removed. Static custom headers of a connected agent are still sent, except Authorization, which always carries the token.
Under the option the screen shows two addresses to give to the team that runs the server:
| Field | Example |
|---|---|
| Issuer | https://console.example.com/api/public/identity/65f0c1d2a3b4c5d6e7f80001 |
| Key set (JWKS) | https://console.example.com/api/public/identity/65f0c1d2a3b4c5d6e7f80001/.well-known/jwks.json |
The workspace's signing key is created the first time an MCP server or connected agent is saved with Console identity, so the key set is published before the first call.
Discovery runs as you
When you save a remote MCP server, Console lists its tools right away. With Console identity that request carries a token for you, the person saving it (src: console), so the server must accept you to list its tools. Refresh tools does the same.
The token
A JWT signed with RS256, header typ: at+jwt (an OAuth access token, RFC 9068) and the kid of the signing key.
| Claim | Meaning |
|---|---|
iss | The issuer above. Built from Console's configured public URL (APP_URL), never from the request. |
aud | The server's URL as configured in Console, without query string, fragment or credentials, and without a trailing slash (the host in lower case, a default port such as :443 left out): the remote MCP endpoint, the OpenAPI server's base URL (one audience per server, not per operation), or the connected agent's endpoint URL. |
sub | The person's Console user id. |
email, name | The person's profile, when set. |
idp, idp_sub | For an account that signs in with SSO (oidc or saml): the identity provider and the person's subject there, for example their Google sub. |
tenant_id, project_id | The workspace, and the project of the call when there is one. |
client_id | Always cognipeer-console. |
src | The channel: portal, api, mcp, a2a, playground, realtime, trigger or console (saving or refreshing a server). |
via | How Console knew the person, see below. |
act | { "sub": "agent:<agent key>" } when an agent makes the call (the actor, RFC 8693). A referenced sub-agent names its own key. |
iat, nbf, exp | Issued now, valid five minutes. |
jti | Unique per token; a server can refuse a jti it has already seen. |
A token is minted for every call; Console does not reuse one.
Who the call is for
| The call comes from | sub is | via |
|---|---|---|
| The dashboard (playground, Studio, MCP playground) or a portal | the signed-in person | session |
| The client API, client MCP endpoint or A2A with an API token | the token's owner | api_token |
The same, acting for a user (X-Cognipeer-User-Token) | the person the statement names | delegated |
| A trigger (schedule, webhook …) | the trigger's accountable person: whoever last saved it | trigger |
| A turn that continues after an approval or an answer | the person the turn is for, not whoever approved it | as for that turn |
| A public MCP endpoint or a public A2A endpoint | nobody: the call is refused |
Console refuses, before anything is sent, a call that is not made for a person, a call for an account that is not in the workspace, and a call for a disabled account. The call fails with a message that starts with Console identity: (see Troubleshooting); it does not count as a fault of the server. A disabled account stops getting tokens within 15 seconds on every node.
Verify the token on your server
- Read the key set from the Key set (JWKS) URL (or from the discovery document at
<issuer>/.well-known/openid-configuration). Cache it and read it again when a token names akidyou do not have. - Accept
RS256only, and check thetypheader isat+jwt. - Check
issis exactly the issuer, andaudis your URL exactly as configured in Console. - Check
expandnbf, allowing a little clock skew (30 seconds is plenty). - Find the person: by
idp_subwhen your server signs the same people in with the same identity provider, otherwise byemail, or bysubif you keep Console user ids.
Node.js (jose):
import { createRemoteJWKSet, jwtVerify } from 'jose';
const ISSUER = 'https://console.example.com/api/public/identity/65f0c1d2a3b4c5d6e7f80001';
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`));
export async function personOf(authorization) {
const token = authorization?.replace(/^Bearer /, '');
const { payload } = await jwtVerify(token, JWKS, {
issuer: ISSUER,
audience: 'https://crm.example.com/mcp',
algorithms: ['RS256'],
typ: 'at+jwt',
clockTolerance: 30,
});
return payload; // sub, email, idp_sub, act, src, via …
}Python (PyJWT):
import jwt
from jwt import PyJWKClient
ISSUER = "https://console.example.com/api/public/identity/65f0c1d2a3b4c5d6e7f80001"
jwks = PyJWKClient(f"{ISSUER}/.well-known/jwks.json")
def person_of(authorization: str) -> dict:
token = authorization.removeprefix("Bearer ")
if jwt.get_unverified_header(token).get("typ") != "at+jwt":
raise jwt.InvalidTokenError("not a Console identity token")
key = jwks.get_signing_key_from_jwt(token)
return jwt.decode(token, key.key, algorithms=["RS256"], issuer=ISSUER,
audience="https://crm.example.com/mcp", leeway=30)An API gateway that validates JWTs from an OpenID issuer (Kong, Envoy, Azure API Management, AWS API Gateway JWT authorizers and others) can be pointed at the issuer or the key set URL instead.
Keys and endpoints
- Each workspace has its own RSA key. The private key never leaves Console and is encrypted at rest; the key set publishes the public half only. A workspace that has not used Console identity publishes an empty set.
- Select the key by
kid: the set can hold more than one key. GET <issuer>/.well-known/jwks.jsonandGET <issuer>/.well-known/openid-configurationneed no token. They answer withCache-Control: public, max-age=300(max-age=30while the set is empty), allow any origin, and accept up to 120 requests a minute from one address (429withRetry-Afterabove that). An unknown workspace is a404.- The issuer comes from
APP_URL. Set it to the public address of Console before you hand the issuer out: changing it changesiss, and servers that check it will refuse the new tokens.
Notes
- Not available for npx / uvx, internal or composite servers: they have no upstream URL of their own. A composite's members that use Console identity mint their own tokens, for their own URLs.
- Caller-supplied runtime headers cannot replace the token: for these servers any
Authorizationheader a caller sends is dropped. - Console masks the token where a server could echo it back into its records: an MCP server's request logs, last error and tool results, and a connected agent's answers (streamed too) and errors.
- An exported agent that embeds its MCP servers keeps Console identity on them. Importing it asks for no credential: the importing workspace signs its own tokens, with its own issuer.
Troubleshooting
| What you see | What it means |
|---|---|
Console identity: this server receives the identity of the person behind each call, and this call is not made for a signed-in person. | The call has nobody behind it: a public MCP or A2A endpoint, or an internal actor. Call through an authenticated channel. |
Console identity: … the person this call is made for is not an account of this workspace. | The account was deleted, or belongs to another workspace. |
Console identity: … the account this call is made for is disabled. | Enable the account, or call as someone else. |
Console identity: Console could not prepare the token for this call. Try again shortly. | The workspace's key or the person's account could not be read (for example the database was unreachable). Retry; if it persists, check that the encryption secret of the deployment has not changed. |
Your server answers 401 | Compare aud with your URL character by character (no query string, no trailing slash), check iss against the issuer, and make sure the server's clock is right. |
Related
- Acting for a user: the inbound side, where an application tells Console who its call is for.
- MCP servers
- Connected agents

