Chat Components

App Credentials

Copy page

How app credentials authenticate end-users, including anonymous sessions, authenticated chat sessions with asymmetric key verification, domain validation, and Proof-of-Work protection.

App credentials are the auth mechanism for chat components. This page covers the underlying auth flow, security model, and advanced configuration.

How anonymous authentication works

When your widget loads, it authenticates through a two-step flow:

  1. Get a session token — the widget calls POST /run/auth/apps/{appId}/anonymous-session. The server validates the request Origin against the app's allowed domains and returns a JWT.
  2. Use the token — subsequent chat requests include the JWT and App ID:
    Authorization: Bearer <session_token>
    X-Inkeep-App-Id: <app_id>

Each session gets a unique anonymous user ID (anon_<uuid>), enabling per-user conversation history.

To refresh a token while preserving the same anonymous identity, include the existing token as an Authorization: Bearer header when requesting a new session. If the token is valid, the server reuses the same user ID (sub claim) and issues a fresh token with a new 30-day expiry. If the token is invalid, expired, or belongs to a different app, a new anonymous identity is created as usual.

Authenticated chat sessions

In addition to anonymous sessions, apps can be configured for authenticated chat sessions. Your backend signs JWTs with a private key, and Inkeep verifies them using the corresponding public key you upload. This gives each session a verified user identity tied to your system's user IDs.

How it works

Generate a key pair — create an asymmetric key pair (RSA, EC, or EdDSA) on your infrastructure.

Upload the public key — register the public key with your app via the API or Visual Builder. Inkeep never sees your private key.

Sign JWTs on your backend — when a user starts a chat session, your server signs a JWT containing the user's identity.

routes/inkeep-token.ts
import { Router } from 'express';
import { fromNodeHeaders } from 'better-auth/node';
import { SignJWT, importPKCS8 } from 'jose';
import { auth } from '../lib/auth'; // your BetterAuth instance

const router = Router();

const PRIVATE_KEY_PEM = process.env.INKEEP_SIGNING_KEY!;
const KEY_ID = process.env.INKEEP_KEY_ID!; // matches the kid uploaded to Inkeep

router.post('/api/inkeep-token', async (req, res) => {
  // Verify the user's session via BetterAuth
  const session = await auth.api.getSession({
    headers: fromNodeHeaders(req.headers),
  });

  if (!session) {
    return res.status(401).json({ error: 'Not authenticated' });
  }

  const now = Math.floor(Date.now() / 1000);
  const privateKey = await importPKCS8(PRIVATE_KEY_PEM, 'RS256');

  const token = await new SignJWT({
    sub: session.user.id,      // becomes the userId in Inkeep
    email: session.user.email, // optional — available as a verified claim
  })
    .setProtectedHeader({ alg: 'RS256', kid: KEY_ID })
    .setIssuedAt(now)
    .setExpirationTime(now + 3600) // 1 hour
    .sign(privateKey);

  return res.json({ token });
});

export default router;

Widget sends the signed JWT — the chat widget includes the JWT as a Bearer token instead of requesting an anonymous session.

Inkeep verifies the signature — the server matches the kid header to a stored public key, verifies the signature, and extracts the user identity from the sub claim.

JWT requirements

FieldLocationRequiredDescription
kidHeaderYesMust match the kid of an uploaded public key
subPayloadYesUser identifier — becomes the userId for the session
expPayloadYesExpiration time, max 24 hours from iat
iatPayloadYesIssued-at time, must be within 60 seconds of server time

Supported algorithms

FamilyAlgorithms
RSARS256, RS384, RS512
ECDSAES256, ES384, ES512
EdDSAEdDSA (Ed25519)
Note
Note

RSA keys must be at least 2048 bits. Private keys are rejected at upload time — only public keys are accepted.

Dual-mode support

Apps with auth keys still serve anonymous sessions by default. If a signed JWT verification fails, the request falls back to anonymous authentication. To require authenticated sessions only, set allowAnonymous to false in the app's auth configuration.

Verified claims

Non-standard claims in the signed JWT (beyond sub, iat, exp, aud, iss, jti, nbf) are extracted and made available as verified claims in the conversation context. These are cryptographically signed and kept separate from unverified userProperties sent by the client.

Warning
Warning

Verified claims are limited to 1KB. Tokens with custom claims exceeding this limit are rejected.

Key management API

Manage public keys for an app using the following endpoints:

MethodEndpointDescription
POST/manage/tenants/{tenantId}/projects/{projectId}/apps/{appId}/auth-keysAdd a public key (max 5 per app)
GET.../apps/{appId}/auth-keysList all public keys
DELETE.../apps/{appId}/auth-keys/{kid}Delete a key by kid

Example — add a public key:

curl -X POST "https://your-api.example.com/manage/tenants/{tenantId}/projects/{projectId}/apps/{appId}/auth-keys" \
  -H "Authorization: Bearer $MANAGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "kid": "my-key-1",
    "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBI...",
    "algorithm": "RS256"
  }'

Create an App via API

curl -X POST "https://your-api.example.com/manage/tenants/{tenantId}/projects/{projectId}/apps" \
  -H "Authorization: Bearer $MANAGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Docs Chat Widget",
    "type": "web_client",
    "defaultAgentId": "your-agent-id",
    "prompt": "Be concise and link to documentation pages when possible.",
    "config": {
      "type": "web_client",
      "webClient": {
        "allowedDomains": ["docs.example.com"]
      }
    }
  }'

See the Apps API Reference for the full CRUD API.

Proof-of-Work Protection

When Proof-of-Work (PoW) is enabled on the server, clients must solve a computational challenge before requesting a session token. This protects against automated abuse.

Note
Note

PoW is optional and controlled by the server administrator via the INKEEP_POW_HMAC_SECRET environment variable. If PoW is not enabled, the challenge endpoint returns 404 and clients skip this step.

Client Integration

Install the solver library:

npm install altcha-lib

Fetch a challenge, solve it, and include the solution when requesting a session token:

import { solveChallenge } from "altcha-lib";

const BASE_URL = "https://your-api.example.com";

async function getPowHeaders(): Promise<Record<string, string>> {
  const res = await fetch(`${BASE_URL}/run/auth/pow/challenge`);

  if (res.status === 404) {
    return {}; // PoW not enabled — skip
  }

  const challenge = await res.json();
  const { promise } = solveChallenge(
    challenge.challenge,
    challenge.salt,
    challenge.algorithm,
    challenge.maxnumber,
  );
  const solution = await promise;

  const payload = btoa(JSON.stringify({
    algorithm: challenge.algorithm,
    challenge: challenge.challenge,
    number: solution?.number,
    salt: challenge.salt,
    signature: challenge.signature,
  }));

  return { "X-Inkeep-Challenge-Solution": payload };
}

Include the returned headers in the anonymous session request:

const powHeaders = await getPowHeaders();

// To refresh an existing session and preserve identity,
// include the current token in the Authorization header.
const existingToken = getStoredToken(); // your token storage logic

const response = await fetch(`${BASE_URL}/run/auth/apps/${APP_ID}/anonymous-session`, {
  method: "POST",
  headers: {
    ...powHeaders,
    ...(existingToken ? { Authorization: `Bearer ${existingToken}` } : {}),
  },
});

if (!response.ok) {
  const error = await response.json();
  throw new Error(error.error?.message || "Session request failed");
}

const { token } = await response.json();

Security Model

FeatureDetails
Domain allowlistOrigin header validated against the app's allowedDomains at token issuance
Scoped accessEach app is bound to a default agent via defaultAgentId
App promptOptional supplemental instructions added to the agent's system prompt for this app
Anonymous identityEach anonymous session gets a unique user ID for per-user conversation history
Authenticated identityUser ID from verified sub claim when using signed JWTs
Verified claimsNon-standard JWT claims from authenticated sessions available in conversation context
Dual-modeApps with auth keys support both anonymous and authenticated sessions
Token expiryAnonymous session tokens default to 30 days; authenticated tokens max 24 hours
PoWOptional Proof-of-Work challenges prevent automated abuse (bypassed for authenticated sessions)
Rolling refreshInclude existing anonymous token when requesting a new session to preserve identity with a fresh expiry

App Credentials vs API Keys

App CredentialsAPI Keys
Use caseBrowser / client-sideServer-to-server
Exposed to end-usersYes (App ID only)No (secret)
Domain restrictionsYesNo
Per-user identityYes (anonymous or authenticated)No
Default agentOne agent (via defaultAgentId)One agent per key