Sign people in: the identity token
An embedded portal has no login of its own. Instead, your server signs a short-lived token for the person who is signed in to your application, and the portal accepts the person on the strength of that signature. This page is the server side: what the token must contain, how to create the key it is signed with, and complete examples for Node.js, Python, Java (Spring) and .NET.
Enterprise module
Embedding is part of the enterprise portals module — see Embedding a portal.
The token is a standard signed JWT (a compact JWS). The platform's shared verified-identity rules check it in this order: size and shape, algorithm (exactly the one pinned for the portal), signature (with only the key material that belongs to that algorithm), then iss, aud, sub, exp and iat, the lifetime, the profile claims and — last — the single use of the jti.
The claims
| Claim | Required | Rules |
|---|---|---|
iss | Yes | Exactly the Issuer you set in the Embed tab. Compared as text — a trailing slash counts. |
aud | Yes | cognipeer-portal:<portal key>, or cognipeer-portal:<workspace slug>:<portal key>. A string, or an array containing it. With Require the workspace in the audience only the second form is accepted. A token for one portal never opens another. |
sub | Yes | The person's id in your system, 1 to 512 characters. Keep it stable: it is half of the person's identity (issuer + subject). |
iat | Yes | Seconds since the epoch. Not more than 60 seconds in the future. |
exp | Yes | Seconds since the epoch. exp - iat must not exceed the portal's Longest token lifetime (default 300, never above 600). A token past exp by more than 60 seconds is token_expired. |
jti | Yes | A fresh unique id for every token, at most 256 characters. A token works once: a second exchange of the same jti is refused as token_replayed. |
email | No | A valid address, at most 320 characters — a value that is present but is not an address makes the whole token invalid. Finds a member only with email_verified: true (or Trust the host's addresses). Keeps an external account's profile current. |
email_verified | No | true only when your application has verified the address. Any other value means "not verified". |
name | No | Display name, trimmed to 200 characters. Keeps an external account's name current; a member's own name is never rewritten. |
groups | No | An array of strings — at most 100, each at most 200 characters, duplicates dropped. The claim is always called groups. A single string is ignored, not wrapped. |
ctx | No | A JSON object up to 4 KB (as serialised JSON) that you vouch for. Delivered to the agent as run context (embedContext); never used to decide access. |
nbf | No | Honoured, with 60 seconds of clock skew. |
The header must carry alg exactly as configured: none and every other algorithm are refused. A header that tries to bring its own key (jwk, jku, x5u) is ignored — only the key you configured in the Embed tab is ever used. With a shared secret put the secret's key id in kid: optional with one secret, required while two are active (a rotation). With a key set (JWKS) always set kid.
The whole token is limited to 8192 characters. Do not put data in it that the portal does not use.
Mint a new token on every call
The widget asks your endpoint when the chat starts, and again before every renewal. Never cache a token, never store it in localStorage, and never reuse one for two frames — the second exchange fails as token_replayed. A token is only meant to live for the seconds between your server signing it and the console exchanging it.
Choose how to sign
| HS256 — shared secret | RS256 / ES256 — key pair | |
|---|---|---|
| Who makes the key | The console generates the secret and shows it once. | You generate the key pair. |
| What the console holds | The secret, encrypted at rest (it needs the secret itself to check a signature). | Only the public key (a PEM) or the address of your key set (JWKS). |
| Rotation | Two secrets active at once; no downtime. | Publish a new key in your JWKS beside the old one, or paste the new PEM. |
| Best when | One application, simplest operations. | Your security policy forbids sharing a secret, or several systems sign for the same portal. |
You choose in the Embed tab under Token signing → Algorithm. A portal accepts exactly one algorithm at a time, and a key that belongs to the other family is never offered to the verifier — a public key's text cannot be used as a shared secret (the classic key-confusion attack has nothing to work with).
HS256: generate, store, rotate
In Operate → Portals → your portal → Embed → Token signing, choose HS256 (shared secret), enter the Issuer, and press Generate a secret.
The console shows the value once, with its key id:
textSecret sec_3f9a1c07d2 cps_Q1x0v8N3kJ2mZ7sH4tR9yLw5uB6eD1aGpC8nXoVfT2iM1Copy both into your server's secret store (environment variables, a vault). The console cannot show the secret again — if you lose it, generate a new one.
Use the secret exactly as shown — as the UTF-8 text
cps_…. Do not base64-decode it; that is the most common cause of a signature that "does not match".
Facts about the secret:
- It is 256 bits of randomness,
cps_followed by 43 URL-safe characters; every shared secret must be at least 32 characters. - The key id is
sec_followed by 10 hexadecimal characters. It is what you put in the token'skidheader. - At most two secrets exist at a time. The Embed tab shows each one's key id, when it was created and when it was last used (updated at most every five minutes).
- Generating and removing secrets is recorded in the portal's History tab by key id — never the value.
Rotate with no downtime
- Press Generate a secret a second time. Both secrets verify tokens from now on.
- Deploy the new secret and key id to your server and start signing with the new
kid. - Wait until the old secret's Last used stops moving (tokens live at most ten minutes).
- Remove the old secret. Tokens signed with it stop working at once.
If a secret leaked, do not wait: remove it, generate another, deploy it — and press Sign everyone out (see Sessions). Removing a secret stops new sign-ins with it; sessions that were already opened keep running until they end.
Last secret
While embedding is on and HS256 is pinned, the console refuses to remove the only secret (HTTP 409, invalid_signing). Generate another first, or switch embedding off.
RS256 and ES256: public keys
Your application keeps the private key. Generate a key pair on a machine you control:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out host-private.pem
openssl pkey -in host-private.pem -pubout -out host-public.pem2
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out host-private.pem
openssl pkey -in host-private.pem -pubout -out host-public.pem2
Keep host-private.pem on your server (PKCS#8, BEGIN PRIVATE KEY). Give the console the public half in one of two ways, chosen under Public key from:
- A pasted key. Paste
host-public.pem(BEGIN PUBLIC KEY, or an X.509BEGIN CERTIFICATE), at most 8192 characters. A private key is refused with "This looks like a private key". RS256 needs an RSA key of at least 2048 bits; ES256 needs an EC key on the P-256 curve (prime256v1). - A key set address (JWKS). An
httpsaddress that serves your public keys as a JSON Web Key Set, at most 1000 characters (httponly forlocalhoston development and self-hosted installations). The Embed tab keeps one source at a time; saving one clears the other.
How the console treats a key set:
| Behaviour | Value |
|---|---|
| Fetched by | The console server, through its outbound-request guard (SSRF protection) — internal and private addresses are refused. The key set must be reachable from the console, not from the person's browser. |
| Timeout / size | 5 seconds / at most 256 KB |
| Cache | Ten minutes |
Unknown kid | Refetched, but at most once every 30 seconds, so a token cannot make the console hammer your endpoint |
| Fetch failed | "No key" — the token is refused as token_invalid (audit reason untrusted_key). The console never falls back to an old copy, and does not ask again for ten seconds. |
| Matching | By the token's kid (and the key's alg/kty). Without a kid a key set with several keys is ambiguous and the token is refused. |
Publishing a key set from Node.js:
import { exportJWK, importSPKI } from 'jose';
import { readFileSync } from 'node:fs';
const publicKey = await importSPKI(readFileSync('host-public.pem', 'utf8'), 'RS256', { extractable: true });
const jwk = { ...(await exportJWK(publicKey)), kid: 'host-key-2026-10', alg: 'RS256', use: 'sig' };
app.get('/.well-known/jwks.json', (req, res) => res.json({ keys: [jwk] }));2
3
4
5
6
7
Rotate a key pair. Add the new public key to your JWKS beside the old one, start signing with the new kid, and remove the old key once its tokens have expired. A new key is usable at once (an unknown kid triggers a refetch); a removed key keeps working until the console's ten-minute cache runs out. With a pasted PEM, replacing the PEM switches the key at once.
The audience: one workspace or many
The token's aud names the portal: cognipeer-portal:support. Portal keys are unique inside one workspace only. If your application serves several workspaces with one signing key or one issuer — you run a SaaS and each customer has their own Cognipeer workspace — a token made for the support portal of workspace A would also open the support portal of workspace B.
For that case turn on Require the workspace in the audience. The portal then accepts only cognipeer-portal:<workspace slug>:<portal key>, for example cognipeer-portal:acme:support. Otherwise (the common case) use a separate key or issuer per workspace, or just leave it off — both forms are accepted.
Mint the token on your server
Never put the signing secret or a private key in code that runs in a browser. Add an endpoint to your server that answers the signed-in person with a fresh token, and let the widget call it from getToken(). The examples below all produce the same token: a 120-second lifetime, a fresh jti, the person's verified email, display name and groups, and a ctx object.
// npm install express jose
import express from 'express';
import { SignJWT } from 'jose';
import { randomUUID } from 'node:crypto';
const ISSUER = 'https://intranet.example.com'; // exactly the Issuer in the Embed tab
const PORTAL = 'support'; // the portal's key
const KEY_ID = process.env.COGNIPEER_EMBED_KEY_ID; // sec_… (shown next to the secret)
// The secret is used exactly as shown: its UTF-8 bytes, not base64-decoded.
const SECRET = new TextEncoder().encode(process.env.COGNIPEER_EMBED_SECRET); // cps_…
const app = express();
app.get('/api/cognipeer-token', requireLogin, async (req, res) => { // requireLogin: your own session check
const now = Math.floor(Date.now() / 1000);
const token = await new SignJWT({
email: req.user.email,
email_verified: true, // only if your application has verified this address
name: req.user.name,
groups: req.user.groups, // an array of strings, even for one group
ctx: { plan: req.user.plan }, // optional: up to 4 KB, vouched for by you
})
.setProtectedHeader({ alg: 'HS256', kid: KEY_ID })
.setIssuer(ISSUER)
.setAudience(`cognipeer-portal:${PORTAL}`)
.setSubject(String(req.user.id))
.setIssuedAt(now)
.setExpirationTime(now + 120)
.setJti(randomUUID())
.sign(SECRET);
res.set('Cache-Control', 'no-store').type('text/plain').send(token);
});
app.listen(3000);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# pip install flask flask-login "pyjwt>=2"
import os
import time
import uuid
import jwt
from flask import Flask, Response
from flask_login import current_user, login_required
app = Flask(__name__)
ISSUER = "https://intranet.example.com" # exactly the Issuer in the Embed tab
PORTAL = "support" # the portal's key
@app.get("/api/cognipeer-token")
@login_required # your own session check
def cognipeer_token():
now = int(time.time())
token = jwt.encode(
{
"iss": ISSUER,
"aud": f"cognipeer-portal:{PORTAL}",
"sub": str(current_user.id),
"iat": now,
"exp": now + 120,
"jti": str(uuid.uuid4()),
"email": current_user.email,
"email_verified": True, # only if your application has verified this address
"name": current_user.name,
"groups": list(current_user.group_names), # a list of strings, even for one group
"ctx": {"plan": current_user.plan}, # optional: up to 4 KB, vouched for by you
},
os.environ["COGNIPEER_EMBED_SECRET"], # cps_…, exactly as shown
algorithm="HS256",
headers={"kid": os.environ["COGNIPEER_EMBED_KEY_ID"]}, # sec_…
)
return Response(token, mimetype="text/plain", headers={"Cache-Control": "no-store"})2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Maven: io.jsonwebtoken:jjwt-api, jjwt-impl (runtime) and jjwt-jackson (runtime), version 0.12.x
import io.jsonwebtoken.Jwts;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
import java.util.Map;
import java.util.UUID;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class CognipeerTokenController {
private static final String ISSUER = "https://intranet.example.com"; // exactly the Issuer in the Embed tab
private static final String PORTAL = "support"; // the portal's key
private final SecretKeySpec key;
private final String keyId;
CognipeerTokenController(@Value("${cognipeer.embed.secret}") String secret, // cps_…
@Value("${cognipeer.embed.key-id}") String keyId) { // sec_…
// The secret is used exactly as shown: its UTF-8 bytes, not base64-decoded.
this.key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
this.keyId = keyId;
}
@GetMapping(value = "/api/cognipeer-token", produces = MediaType.TEXT_PLAIN_VALUE)
ResponseEntity<String> token(@AuthenticationPrincipal AppUser user) { // AppUser: your own principal type
Instant now = Instant.now();
String jwt = Jwts.builder()
.header().keyId(keyId).and()
.issuer(ISSUER)
.audience().add("cognipeer-portal:" + PORTAL).and()
.subject(String.valueOf(user.getId()))
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(120)))
.id(UUID.randomUUID().toString())
.claim("email", user.getEmail())
.claim("email_verified", user.isEmailVerified()) // true only if your application verified it
.claim("name", user.getName())
.claim("groups", user.getGroupNames()) // a List<String>, even for one group
.claim("ctx", Map.of("plan", user.getPlan())) // optional: up to 4 KB, vouched for by you
.signWith(key, Jwts.SIG.HS256)
.compact();
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(jwt);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// dotnet add package Microsoft.IdentityModel.JsonWebTokens
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
// ... your authentication and authorization setup ...
var app = builder.Build();
const string Issuer = "https://intranet.example.com"; // exactly the Issuer in the Embed tab
const string Portal = "support"; // the portal's key
// The secret is used exactly as shown: its UTF-8 bytes, not base64-decoded.
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(app.Configuration["Cognipeer:EmbedSecret"]!)) // cps_…
{
KeyId = app.Configuration["Cognipeer:EmbedKeyId"] // sec_…
};
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var handler = new JsonWebTokenHandler();
app.MapGet("/api/cognipeer-token", [Authorize] (ClaimsPrincipal user, HttpContext http) =>
{
var now = DateTime.UtcNow;
var token = handler.CreateToken(new SecurityTokenDescriptor
{
Issuer = Issuer,
Audience = $"cognipeer-portal:{Portal}",
IssuedAt = now,
NotBefore = now,
Expires = now.AddSeconds(120),
SigningCredentials = credentials,
Claims = new Dictionary<string, object>
{
["sub"] = user.FindFirstValue(ClaimTypes.NameIdentifier)!,
["jti"] = Guid.NewGuid().ToString(),
["email"] = user.FindFirstValue(ClaimTypes.Email)!,
["email_verified"] = true, // only if your application has verified this address
["name"] = user.Identity!.Name!,
// An ARRAY, even for one group (several JwtSecurityToken claims of one type collapse to a string when there is only one).
["groups"] = user.FindAll("group").Select(c => c.Value).ToArray(),
["ctx"] = new Dictionary<string, object> { ["plan"] = "enterprise" }, // optional: up to 4 KB
},
});
http.Response.Headers.CacheControl = "no-store";
return Results.Text(token, "text/plain");
});
app.Run();2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
Your endpoint is the gate
Whoever can call this endpoint can sign in as the person it names. Authenticate the request with your own session, take the person only from that session (never from a query parameter), return Cache-Control: no-store, and keep it same-origin so no CORS policy is needed. If you rate-limit it, remember the widget calls it once per session start and once per renewal.
The same with RS256 or ES256
The code is the same with the private key and the algorithm swapped; the console only ever holds the public half. Put the key's kid in the header when you publish a key set.
import { importPKCS8, SignJWT } from 'jose';
import { readFileSync } from 'node:fs';
const privateKey = await importPKCS8(readFileSync('host-private.pem', 'utf8'), 'RS256'); // or 'ES256'
const token = await new SignJWT({ email, email_verified: true, name, groups })
.setProtectedHeader({ alg: 'RS256', kid: 'host-key-2026-10' })
.setIssuer(ISSUER)
.setAudience(`cognipeer-portal:${PORTAL}`)
.setSubject(String(user.id))
.setIssuedAt(now)
.setExpirationTime(now + 120)
.setJti(randomUUID())
.sign(privateKey);2
3
4
5
6
7
8
9
10
11
12
13
14
# pip install "pyjwt[crypto]"
with open("host-private.pem") as f:
private_key = f.read()
token = jwt.encode(claims, private_key, algorithm="RS256", # or "ES256"
headers={"kid": "host-key-2026-10"})2
3
4
5
6
String pem = Files.readString(Path.of("host-private.pem"))
.replace("-----BEGIN PRIVATE KEY-----", "").replace("-----END PRIVATE KEY-----", "");
PrivateKey privateKey = KeyFactory.getInstance("RSA") // "EC" for ES256
.generatePrivate(new PKCS8EncodedKeySpec(Base64.getMimeDecoder().decode(pem)));
String jwt = Jwts.builder()
.header().keyId("host-key-2026-10").and()
/* ... same claims ... */
.signWith(privateKey, Jwts.SIG.RS256) // Jwts.SIG.ES256 for EC
.compact();2
3
4
5
6
7
8
9
10
var rsa = RSA.Create();
rsa.ImportFromPem(File.ReadAllText("host-private.pem"));
var credentials = new SigningCredentials(new RsaSecurityKey(rsa) { KeyId = "host-key-2026-10" }, SecurityAlgorithms.RsaSha256);
// ES256:
// var ec = ECDsa.Create();
// ec.ImportFromPem(File.ReadAllText("host-private.pem"));
// var credentials = new SigningCredentials(new ECDsaSecurityKey(ec) { KeyId = "host-key-2026-10" }, SecurityAlgorithms.EcdsaSha256);2
3
4
5
6
7
8
The signed context: ctx
ctx carries facts your application vouches for — a plan, a tenant id, a role in your system. It is stored with the session when the token is exchanged, so a renewed session carries the new value, and it reaches the agent as run metadata embedContext. It is never used to decide who the person is or what they may do, and what the agent does with it depends on the agent's design.
Facts the page wants to tell the agent about what is on screen are a different thing — they are not signed. See Page context.
Call the endpoint from your page
The widget calls getToken for you — see The widget and JavaScript API. The function only needs to return the token text:
getToken: async ({ reason }) => {
// reason: 'initial' the first time, then 'expiring', 'expired' or 'rejected'
const response = await fetch('/api/cognipeer-token', { credentials: 'include' });
if (!response.ok) throw new Error('Could not get a token');
return response.text();
},2
3
4
5
6
Check your tokens before you ship
The Try it page. In the Embed tab press Open the test page. Under Paste a token paste a token your server just signed and press Start: it is verified exactly like a real one, and a refusal shows its reference code. (A pasted token works once — mint a new one for each run.)
Look at your claims. A token is only base64 — decoding it consumes nothing:
bashnode -e "console.log(JSON.parse(Buffer.from(process.argv[1].split('.')[1], 'base64url')))" "<token>"1Look at the refusal. Every refused exchange is in Configure → Audit Log (service
portals, eventportal.embed.refused) with the exact reason. See Troubleshooting.
Common mistakes
| Mistake | What you see | Fix |
|---|---|---|
Base64-decoding the cps_… secret | token_invalid — "The assertion signature does not match" | Use the text exactly as shown (its UTF-8 bytes). |
No email_verified: true with the default mapping | identity_not_found | Send email_verified: true for addresses you verified, or match by identity, or enable Trust the host's addresses. |
| One group sent as a string | The group is silently ignored | Always send groups as an array. |
| A lifetime above the portal's limit | token_invalid — "valid for longer than allowed" | Keep exp - iat at 120–300 seconds. |
| Caching or reusing a token | token_replayed | Mint a new token for every call. |
| A server clock that drifts | token_expired, or "issued in the future" | Keep your server on NTP; the console tolerates 60 seconds. |
iss or aud that differs by a character | token_invalid — "claim is not acceptable" | Copy the issuer and audience from the Embed tab. |
Two secrets active and no kid | token_invalid — "no key id and several secrets are configured" | Always send kid. |
A sub that changes (an email today, a numeric id tomorrow) | A new external account for the same person | Choose a stable sub once. |
| A JWKS the console cannot reach | token_invalid — untrusted_key | Publish it on a public https address; the console fetches it server-side. |

