Authentication & Auth Broker
Priya messages Acme Diagnostics to ask for her lab results. The agent is happy to chat — but it will not read her private reports back to her until Acme's own systems confirm she's really Priya. That confirmation is a decision Perfox never makes on its own. By the end of this page you'll know how a conversation goes from anonymous to authenticated, and why the credential that proves it is one that YOU sign.
This page is for developers wiring an agent to protected customer data. You'll walk away understanding two separate ideas — who the customer is (identity) and whether their protected data may be served (authentication) — and how Perfox carries your signed proof without ever reading it. If you're building the tools this plugs into, keep Building Your Own MCP Server open alongside.
Every conversation starts anonymous
There is no implicit authentication. Every conversation begins anonymous, even when a channel asserts an identity. A channel-asserted identity — a caller-ID, a phone number — is a low-trust signal, not proof of the human on the other end. Anyone can spoof a caller-ID.
Elevation to authenticated is your explicit decision. Until it happens, the customer's private data stays gated: your tools still run, but the documents on file for that customer, and the customer details Perfox loads from your system into the conversation, are withheld until authentication is stamped. So Priya's agent can answer "what are your opening hours" from the start, yet "read me my last blood report" waits for the stamp.
Cross-channel identity resolution
Identity is a separate question from authentication. Identity is who the customer is, so their record stays stable across channels. Before creating a new record, Perfox looks up the incoming contact by phone, then email, then WhatsApp — canonicalising the phone number to full international (E.164) format so the same number matches no matter how it arrives. The result: the same person on web, WhatsApp, phone, and email resolves to ONE customer.
- A visitor with no identity gets a temporary anonymous customer record for that visit, so separate anonymous sessions never bleed into each other.
- Every inbound channel is added to the customer's list of known channels, so their timeline stays unified.
Identity resolution locates the record. The auth broker below decides whether that record's protected data may be served.
The auth-context broker
The broker is the ONLY authenticated-identity path for your MCP tools. Your own server tells Perfox when a conversation is authenticated by calling back to a per-conversation auth-context endpoint. Perfox stores the credential you send and forwards it as X-Sa-* headers on later tool calls. This callback path is live.
A self-describing callback
You register no webhook and hardcode no URL. On every tool call, Perfox hands your MCP server three headers:
text
X-Sa-Conversation-Id : <conversation_id>
X-Sa-Auth-Callback : https://<your-workspace>-api.perfox.ai/conversations/<conversation_id>/auth-context
X-Sa-Auth-Callback-Token : <short-lived signed token — opaque to you; echo it back>When your server decides the end-user is authenticated, it POSTs that decision to the URL it was handed:
http
POST <value of X-Sa-Auth-Callback>
Authorization: Bearer <value of X-Sa-Auth-Callback-Token>
Content-Type: application/json
{
"authenticated": true,
"forward_payload": "<YOUR signed token/JWT/blob>",
"forward_as": "header:X-Acme-Auth",
"user_context": { "external_id": "cust_123", "name": "Asha", "attributes": { "plan": "gold" } },
"trust": "verified",
"expires_at": "2026-07-01T00:00:00Z"
}The callback body carries authenticated, forward_payload, forward_as, user_context, trust, and expires_at.
| Field | What it does |
|---|---|
authenticated | true stamps the conversation authenticated (unlocks the customer's private documents and details); false logs it back out. |
forward_payload | A credential you sign. Stored verbatim and opaque — Perfox never reads it — and replayed on every later tool call. |
forward_as | Where to replay the payload: header:<name>, body:<key>, or query:<name>. |
user_context | Display identity for the operator UI (put domain fields in attributes, never top-level). |
trust | The trust level of this assertion. Perfox uses it on its own side; no header carries it to your server, which draws its own conclusions from what it verified. |
expires_at | When to stop replaying the payload. Enforced — once it passes, the credential is no longer sent. It does not sign the person out; the customer's private data stays available until you send authenticated: false. |
authenticated is required and must be a real boolean — a missing value is rejected rather than treated as a sign-out.
Each callback is a partial update
You do not have to resend everything every time. Omit forward_payload and we keep replaying the credential you already gave us — so a callback that only refreshes user_context, or re-asserts that someone is still signed in, will not quietly strip their credential. Send a new string to rotate it, or null to clear it while they stay signed in. forward_as behaves the same way.
expires_at is the one exception, because an expiry belongs to the specific credential it was issued with: when you rotate the payload, send its expires_at again. If you do not, the replacement is unbounded — which is allowed, since some businesses issue non-expiring tokens, but it is never silent: the conversation timeline shows the credential has no expiry, and we log the expiry that was dropped.
When your callback takes effect
The forwarding context is assembled once per turn, so a callback you POST from inside a tool handler applies from the next turn — the remaining tool calls of the turn that triggered your login still carry the pre-login context. This keeps every tool call within a turn consistent with each other. If your server needs continuity within that turn, key your session on the conversation id, which is present on the very first call, and treat the replayed credential as reinforcement rather than the only carrier.
Seeing what happened
Sign-ins, sign-outs, expiries and rejected callbacks all appear as entries on the conversation's timeline, so a misconfigured integration is visible rather than silent. A successful callback returns { "ok": true, "auth_state": "..." }; a rejected one returns a 400 naming the problem (missing_authenticated, bad_forward_as, bad_expires_at). A 401 means the callback token was missing, wrong, or expired — it lasts an hour and is reissued on every tool call, so use the one from the most recent call.
Walkthrough: Priya reads her lab report
Here's the whole flow end to end, from Priya's ask to the report on her screen.
Setup. Acme Diagnostics runs its own MCP server with a get_lab_report tool. That tool must not return private results to just anyone. Acme already has a login system that can verify Priya and mint a signed token.
Action. Priya opens the Acme chat widget and types "Can I see my latest blood report?" The agent calls Acme's get_lab_report tool. On that call, Perfox hands Acme's server the three X-Sa-* headers above, including a fresh callback URL scoped to Priya's conversation. Acme's tool sees the conversation isn't authenticated yet, so it walks Priya through its own login (an OTP to her registered phone). Once she passes, Acme's server POSTs to the callback URL it was handed:
http
POST https://acme-api.perfox.ai/conversations/conv_9f2/auth-context
Authorization: Bearer <the X-Sa-Auth-Callback-Token value>
Content-Type: application/json
{
"authenticated": true,
"forward_payload": "<Acme-signed session JWT for Priya>",
"forward_as": "header:X-Acme-Auth",
"user_context": { "external_id": "cust_9021", "name": "Priya", "attributes": { "clinic": "central" } },
"trust": "verified",
"expires_at": "2026-07-01T00:00:00Z"
}Result. Perfox stamps the conversation authenticated, unlocks Priya's private documents and details, and replays Acme's signed JWT as the X-Acme-Auth header on every later tool call. Priya then sees:
Agent: Thanks, Priya — you're verified. Here's your most recent report from 12 June. Haemoglobin is 13.2 g/dL, within the normal range. Would you like the full PDF?
What just happened. Priya's identity was already resolved from her phone number, so her record was stable before login. But her private results stayed gated until Acme — not Perfox — confirmed she was authenticated. From then on, Acme's own tools trust the replayed X-Acme-Auth credential because Acme signed it themselves.
Two lanes: identity vs. credential
The callback carries two separate things:
user_context— who the customer is, for Perfox's own UI. Perfox reads this.forward_payload— your signed credential, for your tools. Perfox never reads it; it replays it to you verbatim. Your MCP verifies its own signature on the next call, which is exactly why Perfox can act as an untrusted courier. A forged or tampered payload fails your check.
Authenticating the callback
The auth-context callback is authenticated by either the per-conversation signed token (the X-Sa-Auth-Callback-Token value) or a workspace API key (sk_…). The per-conversation token is minted per conversation, so possessing it proves you are the server Perfox is talking to for that conversation. That's the confused-deputy guard that stops context from being set on a conversation that is not yours.
You can now recognise callers and gate their private data
You now know how a conversation starts anonymous, how the same person resolves to one record across channels, and how your own server stamps authentication with a credential Perfox carries but never reads.
Where to go next:
- Building Your Own MCP Server — the tool contract this plugs into, plus the
X-Sa-End-User-*identity headers your tools receive. - Authenticated Customer-Data Agent — this exact flow built step by step, end to end.
- Agent Memory — what the agent remembers about a customer across conversations.