> ## Documentation Index
> Fetch the complete documentation index at: https://docs.praxis-ai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Verified identity

> Check the signed x-pria-* headers Pria sends to your MCP server, so your tools can trust which user a call is for.

When a Digital Twin calls your MCP server, Pria adds headers that say which signed-in user the call is for, and signs them with the twin's **MCP Signing Secret**. The identity comes from the user's login session, not from anything the AI wrote, so a crafted prompt cannot change it. Check the signature on your server and you can safely answer per-user questions: a learner's own progress, their quiz results, their tutor's notes. This page is the complete contract: the headers, the rule that produces the signature, verifiers for Node and Python, and a worked example you can check your own code against.

## The headers

| Header                  | Value                                                                                                                                                                          |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `x-pria-user-email`     | The user's email. Sent **only when the user has one**; guest and embed identities may have none. When absent, it is left out of the signature too.                             |
| `x-pria-user-id`        | The user's Pria id.                                                                                                                                                            |
| `x-pria-institution-id` | The Digital Twin's id.                                                                                                                                                         |
| `x-pria-server-label`   | The name you gave this connector in the Digital Twin's admin settings. It binds the signature to your server: a signature made for another connector does not verify on yours. |
| `x-pria-timestamp`      | When the headers were signed, in epoch **milliseconds** (for example `1758000000000`).                                                                                         |
| `x-pria-nonce`          | A UUID, new each time Pria builds the twin's tool list.                                                                                                                        |
| `x-pria-key-id`         | The first 8 hex characters of the SHA-256 of the signing secret. It tells you which secret signed the call, without revealing it.                                              |
| `x-pria-signature`      | 64 lowercase hex characters: the HMAC-SHA256 described below.                                                                                                                  |

Header names can arrive in any letter case, so match them case-insensitively.

<Note>
  When Pria calls your server itself (every conversation model except OpenAI models with native MCP support), each tool call is signed on its own, with a fresh nonce and timestamp. On OpenAI models with native MCP support the headers are fixed when Pria builds the tool list and reused for every call that list serves: once per reply in chat, and for the life of the voice session's cached context in voice and realtime voice, so a long uninterrupted voice session can reach the 60-minute window. Several calls can therefore carry the same nonce, so **do not reject a repeated nonce as a replay**.
</Note>

## The rule

1. Take the seven headers listed above that are present, **except** `x-pria-signature`: six fields, or seven when the email is sent. Ignore any other `x-pria-*` header.
2. Lowercase each header name and sort the fields by that name.
3. Write each field as `name=value` and join them with a single newline (`\n`). No trailing newline.
4. Compute HMAC-SHA256 of that string (UTF-8) with the **MCP Signing Secret** as the key. Use the secret exactly as the text shown in the admin screen: do not hex-decode it.
5. Accept the call only if `|now − x-pria-timestamp| ≤ 60 minutes`, and the lowercase hex digest equals `x-pria-signature` (compared in constant time).

`x-pria-user-id`, `x-pria-institution-id`, `x-pria-server-label`, `x-pria-timestamp` and `x-pria-nonce` must all be present. A request that carries only the email, user id and Digital Twin id, with no signature, was sent unsigned, for example because the twin has no signing secret yet. Treat it as unverified.

## Verifiers

Both check staleness first and the signature second, the same order Pria's own verifier uses, so the reason codes match. Each returns `{ ok: true, keyId }` or `{ ok: false, reason }`.

<CodeGroup>
  ```js verify-pria-identity.js theme={null}
  const crypto = require('crypto')

  const NAMES = ['x-pria-user-email', 'x-pria-user-id', 'x-pria-institution-id', 'x-pria-server-label', 'x-pria-timestamp', 'x-pria-nonce', 'x-pria-key-id']
  function verifyPriaIdentity(signingSecret, headers, { maxAgeMs = 60 * 60 * 1000, now = Date.now() } = {}) {
      const h = {}
      for (const [k, v] of Object.entries(headers)) h[k.toLowerCase()] = String(v)
      const fields = {}
      for (const n of NAMES) if (h[n] != null) fields[n] = h[n]
      for (const r of ['x-pria-user-id', 'x-pria-institution-id', 'x-pria-server-label', 'x-pria-timestamp', 'x-pria-nonce']) if (fields[r] == null) return { ok: false, reason: 'missing-field' }
      const canonical = Object.keys(fields).sort().map((k) => `${k}=${fields[k]}`).join('\n')
      // Same order as Pria's own verifyIdentity: staleness first, then the signature — so the reason codes match.
      const ts = Number(fields['x-pria-timestamp'])
      if (!Number.isFinite(ts) || Math.abs(now - ts) > maxAgeMs) return { ok: false, reason: 'stale' }
      const expected = Buffer.from(crypto.createHmac('sha256', String(signingSecret)).update(canonical, 'utf8').digest('hex'))
      const got = Buffer.from(String(h['x-pria-signature'] || '').toLowerCase())
      // Compare byte lengths, not string lengths: timingSafeEqual throws on unequal buffers.
      if (expected.length !== got.length || !crypto.timingSafeEqual(expected, got)) return { ok: false, reason: 'bad-signature' }
      return { ok: true, keyId: fields['x-pria-key-id'] }
  }

  // In an Express handler (Node already lowercases req.headers):
  // const result = verifyPriaIdentity(process.env.PRIA_MCP_SIGNING_SECRET, req.headers)
  // if (!result.ok) return res.status(401).json({ error: result.reason })
  ```

  ```python verify_pria_identity.py theme={null}
  import hashlib
  import hmac
  import time

  NAMES = ['x-pria-user-email', 'x-pria-user-id', 'x-pria-institution-id', 'x-pria-server-label',
           'x-pria-timestamp', 'x-pria-nonce', 'x-pria-key-id']
  REQUIRED = ['x-pria-user-id', 'x-pria-institution-id', 'x-pria-server-label', 'x-pria-timestamp', 'x-pria-nonce']


  def verify_pria_identity(signing_secret, headers, max_age_ms=60 * 60 * 1000, now_ms=None):
      h = {str(k).lower(): str(v) for k, v in headers.items()}
      fields = {n: h[n] for n in NAMES if n in h}
      if any(r not in fields for r in REQUIRED):
          return {'ok': False, 'reason': 'missing-field'}
      canonical = '\n'.join(f'{k}={fields[k]}' for k in sorted(fields))
      # Same order as Pria's own verifier: staleness first, then the signature.
      now_ms = int(time.time() * 1000) if now_ms is None else now_ms
      try:
          ts = float(fields['x-pria-timestamp'])
      except ValueError:
          return {'ok': False, 'reason': 'stale'}
      if ts != ts or abs(now_ms - ts) > max_age_ms:  # ts != ts rejects NaN
          return {'ok': False, 'reason': 'stale'}
      expected = hmac.new(str(signing_secret).encode('utf-8'), canonical.encode('utf-8'), hashlib.sha256).hexdigest()
      got = h.get('x-pria-signature', '').lower()
      # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
      if not hmac.compare_digest(expected.encode('utf-8'), got.encode('utf-8')):
          return {'ok': False, 'reason': 'bad-signature'}
      return {'ok': True, 'keyId': fields.get('x-pria-key-id')}
  ```
</CodeGroup>

| Reason          | Meaning                                                                                                                                                                            |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `missing-field` | One of the five required headers is absent. The call is unsigned or was stripped in transit.                                                                                       |
| `stale`         | The timestamp is more than 60 minutes from your clock, or is not a number. Check your server's clock first.                                                                        |
| `bad-signature` | The signature does not match: a header was changed, the wrong secret is configured, or the secret was regenerated. Compare `x-pria-key-id` with the key id of the secret you hold. |

## Worked example

Run your verifier on this input before you point it at real traffic. The secret is a dummy: never use it for anything else.

Signing secret (64 characters, used as text):

```text theme={null}
0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
```

Headers received:

```text theme={null}
x-pria-user-email: learner@example.com
x-pria-user-id: 64b000000000000000000001
x-pria-institution-id: 64b000000000000000000002
x-pria-server-label: catalog
x-pria-timestamp: 1758000000000
x-pria-nonce: 11111111-2222-4333-8444-555555555555
x-pria-key-id: a8ae6e6e
x-pria-signature: 856beaf60e86b85ebff5785af750ba845b5a55559400115c172ab1bfd1d92e5f
```

The string that is signed, the seven fields sorted by name and joined by newlines:

```text theme={null}
x-pria-institution-id=64b000000000000000000002
x-pria-key-id=a8ae6e6e
x-pria-nonce=11111111-2222-4333-8444-555555555555
x-pria-server-label=catalog
x-pria-timestamp=1758000000000
x-pria-user-email=learner@example.com
x-pria-user-id=64b000000000000000000001
```

The expected signature is `856beaf60e86b85ebff5785af750ba845b5a55559400115c172ab1bfd1d92e5f`, and the key id of this secret is `a8ae6e6e`.

The timestamp is September 16, 2025, so checked against today's clock the example returns `stale`. That is correct. To reproduce the pass, give the verifier a clock one second after the timestamp: `now: 1758000001000` in Node, `now_ms=1758000001000` in Python. It then returns `ok` with key id `a8ae6e6e`. Change any header value and it returns `bad-signature`.

## Which secret

<Warning>
  Two secrets sit side by side in the Digital Twin's settings, and only one of them verifies these headers.

  * **MCP Signing Secret**: what Pria signs the identity headers with when it calls your connector (outbound). **Use this one.**
  * **MCP Server Secret** (labelled `MCP Secret` in some versions of the editor): what an MCP client sends to call the Digital Twin's own MCP server (inbound). It is not used for signing, and verifying with it always fails with `bad-signature`.
</Warning>

Ask the Digital Twin's administrator to share the MCP Signing Secret through a secure channel, not email.

## Rotation

Regenerating the MCP Signing Secret takes effect immediately. Pria never signs with the old and new secrets at the same time, so plan the change together with the twin's administrator:

1. Pick a quiet hour and have the administrator regenerate the secret and send you the new value.
2. Install the new secret. The new `x-pria-key-id` tells you which calls it signed.
3. A realtime voice session that started before the change keeps the headers it began with. For the next 60 minutes, accept either secret, choosing by `x-pria-key-id`, then remove the old one.

The key id of a secret is the first 8 hex characters of its SHA-256, so you can compute it yourself: in the worked example, the dummy secret's key id is `a8ae6e6e`.

## Related

* [MCP Servers (admin)](/mdx/admin-guide/mcp-servers#verified-learner-identity-on-tool-calls): what the signed identity enables
* [Connectors](/mdx/admin-guide/connectors): connector configuration reference
* [MCP Server](/mdx/integrations/mcp/introduction): both MCP roles in Pria
