> For clean Markdown of any page, append .md to the page URL. > For a complete documentation index, see https://docs.withpersona.com/2020-05-18/relay-privacy-pass-challenge/llms.txt. > For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.withpersona.com/_mcp/server. # Challenge Mechanism > How Persona's Privacy Pass challenge-response flow works. Issuing a Privacy Pass requires implementing a challenge-response protocol based on [RFC 9578](https://www.rfc-editor.org/rfc/rfc9578) and [RFC 9577](https://www.rfc-editor.org/rfc/rfc9577). > **Note** > > If you don't want to implement this yourself, use the [Server SDK](/relay-sdk-quickstart) or [Gateway Service](/relay-gateway-service-usage) > — they handle the cryptography for you. ## Step 1 — Get the challenge Request a challenge for the claim type you want to verify by calling the challenge endpoint. This call is unauthenticated and is not tied to a specific relay — it only needs a `claim-type` in the request body. ```bash POST https://api.withpersona.com/api/privacy/v1/relays/challenge Content-Type: application/json ``` ```json // Request { "claim-type": "live_human_presence" } ``` ```json // Response: 200 OK { "challenge": "", "token-key": "", "token-key-id": "" } ``` | Field | Description | | -------------- | ------------------------------------------------------------------------------------ | | `challenge` | A base64url-encoded JSON object containing metadata about the token being requested | | `token-key` | Persona's RSA public key, base64url-encoded DER. Used to blind your token input | | `token-key-id` | SHA-256 of the public key, included as a convenience so you don't have to compute it | > **Issue against the same claim type as your relay** > > The `token-key` you receive is determined by the `claim-type` in your request, > so the resulting Privacy Pass is bound to that claim type. A pass can only > redeem a relay created with the **same** claim type — if they don't match, the > pass won't be able to redeem the claim. Always request the challenge with the > same claim type you used to create the relay. ### What's inside the challenge Decode `challenge` from base64url to get the following JSON: ```json { "token_type": 2, "issuer_name": "withpersona.com/api/v1/privacy-passes", "origin_info": "/api/privacy/v1/relays", "expires_at": "2026-05-18T18:00:00Z", "mac": "" } ``` | Field | Description | | ------------- | -------------------------------------------------------------------------------------------------------- | | `token_type` | `2` = Blind RSA. Always `2` for Relay | | `issuer_name` | The issuance endpoint where you POST your blinded token | | `origin_info` | The redemption endpoint this token is scoped to. Tokens cannot be used at other endpoints | | `expires_at` | Token expiry, rounded up to the nearest hour. Gives a 1–2 hour issuance window | | `mac` | HMAC of the above fields. Persona uses this to verify the challenge wasn't tampered with during issuance | The `mac` binds the expiry and endpoint scope into the signed token — if a client tries to forge a challenge with a longer expiry, the MAC fails and the token is rejected at issuance. ## Step 2 — Construct the token input Construct a 98-byte token input. Each component is SHA-256 hashed before concatenation: ``` token_input = 0x0002 || SHA256(nonce) || SHA256(challengeMac) || SHA256(keyId) ``` | Component | Description | | ---------------------- | --------------------------------------------------------- | | `0x0002` | 2-byte token type (Blind RSA) | | `SHA256(nonce)` | SHA-256 of a random 32-byte hex nonce you generate | | `SHA256(challengeMac)` | SHA-256 of the `mac` field from the decoded challenge | | `SHA256(keyId)` | SHA-256 of the `token-key-id` from the challenge response | > **Note** > > **Spec deviation:** RFC 9578 §6.1 puts raw bytes directly into `token_input`. > We hash each component to SHA-256 first. Your implementation must match this > exactly — using the raw values will produce an invalid token. ## Step 3 — Blind, sign, and unblind See [Blind RSA](/relay-privacy-pass-blind-rsa) for a full explanation of the protocol. In summary: 1. **Blind** the token input using Persona's public key (`token-key`), producing `blindedMsg` and blinding inverse `inv` 2. **Submit** the blinded token to `POST https://api.withpersona.com/api/v1/privacy-passes` for signing (see [API Usage](/relay-api-usage#issuing-a-privacy-pass)) 3. **Unblind** the returned `blind-sig` using `inv` to produce the final RSA-PSS signature ## Step 4 — Construct the Privacy Pass token Assemble the final token payload: ```json { "token_nonce": "", "signature": "", "key_id": "", "challenge": { "" } } ``` Base64url-encode this JSON object. The result is your `privacy-pass-token`, ready to be used in the redemption call. > **Note** > > Use the same `nonce` string here that you used in `token_input`. The verifier > recomputes `SHA256(nonce)` during redemption — using a different value will > fail signature verification. ## What Persona verifies at redemption When you submit the `PrivateToken`, Persona performs these checks in order: 1. **Challenge MAC** — recomputes the HMAC over the challenge fields and compares against `challenge.mac` 2. **Expiry** — checks that `challenge.expires_at` hasn't passed (challenges are valid for 1–2 hours) 3. **RSA-PSS signature** — reconstructs `token_input` and verifies the unblinded signature against Persona's public key 4. **Double-spend** — checks `token_nonce` against a one-time-use tracker. The same token cannot be redeemed twice If all checks pass, the claim is returned. Persona never learns which customer made the request — only that a valid token was presented. > **Note** > > Use the [Implementation Verification](/relay-privacy-pass-implementation-verification) page to test > your `buildTokenInput()`, `blind()`, and `finalize()` implementations against > fixed test vectors before going live. > How Persona's Privacy Pass challenge-response flow works.