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

# Passkeys for Secure Payment Confirmation (3-D Secure)

> Add passkey Secure Payment Confirmation (SPC) to your Access Control Server: enroll payment passkeys and verify the signed transaction as the FIDO relying party.

Secure Payment Confirmation (SPC) lets a cardholder approve a **specific transaction** (a given
amount and payee) with a passkey. The approval is signed and tamper-evident: the amount, currency
and payee are bound into the assertion, so it proves the cardholder authorised *that* payment, not
just that they authenticated.

SPC is part of **EMV 3-D Secure (3DS) 2.3.1**. It replaces an OTP or out-of-band challenge in the
3DS step-up with a one-tap biometric. Authsignal is the FIDO engine behind your **Access Control
Server (ACS)**: it enrolls payment passkeys and verifies the signed transaction. Your ACS keeps the
3DS message rails and the authentication value it returns to the scheme.

## Where Authsignal fits

In SPC the **ACS is the WebAuthn relying party (RP)**. It owns credential enrollment (the issuer
domain), stores the public key, issues the challenge, and cryptographically **verifies the
assertion**. This is the reverse of non-SPC FIDO, where the merchant is the RP and the ACS only
consumes the attestation as a risk signal it does not verify.

Authsignal provides that relying party. It slots in wherever your ACS makes an authentication
decision, and never touches the PAN or the 3DS message rails.

| Authsignal (your RP engine) | Your ACS / 3DS stack |
| - | - |
| Enroll a payment-capable passkey | 3DS messaging (`AReq`/`ARes`, `CReq`/`CRes`) |
| Issue the SPC request data (`rpId`, `challenge`, credential IDs, amount) | Deliver it in the 3DS message or challenge iframe |
| Verify the assertion and its transaction binding | Compute the authentication value (CAVV/AAV) + ECI |

> [!NOTE]
>
> The transaction is **set server-side** (when you `track` the action) and validated server-side by
> Authsignal. The cardholder's browser only renders what the server issued, and nothing
> client-supplied is trusted for the amount or payee.

## Prerequisites

* **EMV 3DS 2.3.1**, which defines the SPC data elements. The ACS advertises SPC support for a card
  range with `acsInfoInd` = `07`.
* A **platform authenticator** registered with the WebAuthn `payment` extension: Touch ID, Windows
  Hello, or a Google Password Manager passkey on Android. Authsignal marks a credential
  payment-capable only when the browser returns a successful `payment` extension result at
  registration.
* **Browser support**: SPC runs on Chromium browsers (Chrome on macOS, Windows, and Android).
  Feature-detect with `PaymentRequest.securePaymentConfirmationAvailability()` and fall back on
  Safari and Firefox.
* A **`Permissions-Policy`** of `payment=*, publickey-credentials-get=*` on whichever page runs the
  ceremony (the ACS challenge iframe, or the merchant page).

## The two SPC flows

EMV 3DS defines two ways to run SPC. Authsignal's role, and its API calls, are **identical in both**;
only where the browser ceremony runs and how the data travels in the 3DS messages differ.

> [tab] ACS-initiated (recommended)
>
> The ACS decides a challenge is needed and runs the SPC ceremony **inside its own challenge
> iframe** (`CReq`/`CRes`). No merchant changes are required, so it lights up passkeys for every
> merchant that already integrates 3DS.
>
> * `AReq`: `threeDSRequestorSpcSupport` is **omitted** (setting it triggers the requestor flow).
> * `ARes`: `transStatus` = `C` with `authenticationMethod` = `14` (SPC).
> * The ACS renders the `PaymentRequest`, verifies the assertion, and completes the challenge with
>   `CRes`.
>
> ```mermaid theme={null}
> sequenceDiagram
> autonumber
> participant B as Cardholder browser
> participant M as Merchant / 3DS Server
> participant ACS as ACS (issuer)
> participant AS as Authsignal
> B->>M: Checkout
> M->>ACS: AReq (threeDSRequestorSpcSupport omitted)
> ACS->>AS: track (set amount, currency, payee)
> ACS->>AS: authentication-options (securePaymentConfirmation)
> AS-->>ACS: rpId, challenge, credentialIds, payment
> ACS-->>M: ARes transStatus=C, authenticationMethod=14
> Note over B,ACS: Challenge flow (CReq / CRes) in the ACS iframe
> ACS->>B: Render PaymentRequest with SPC data
> B->>B: Cardholder approves with biometric
> B-->>ACS: Signed assertion (payment.get)
> ACS->>AS: verify (signature + transaction binding)
> AS-->>ACS: isVerified
> ACS-->>M: CRes transStatus=Y + authentication value (CAVV/AAV) + ECI
> ```

> [tab] Requestor-initiated
>
> The merchant / 3DS Requestor page runs the ceremony. This gives the merchant control of the UI
> but is the more involved flow: the merchant must integrate the SPC API, and the transaction
> takes **two `AReq` round-trips** (request the SPC data, then return the assertion).
>
> * `AReq #1`: `threeDSRequestorSpcSupport` = `Y`.
> * `ARes #1`: `transStatus` = `S`, carrying the SPC request data (`webAuthnCredentialList`,
>   `challenge`, `payeeName`, `payeeOrigin`, `value`/`currency`, `timeout`).
> * The merchant runs the `PaymentRequest`, then returns the assertion in a **second `AReq`** with
>   `threeDSReqAuthData` and `threeDSReqAuthMethod` = `09` (prior `threeDSReqPriorAuthMethod` =
>   `05`). The ACS verifies and returns `transStatus` = `Y`.
>
> ```mermaid theme={null}
> sequenceDiagram
> autonumber
> participant B as Cardholder browser
> participant M as Merchant / 3DS Requestor
> participant ACS as ACS (issuer)
> participant AS as Authsignal
> B->>M: Checkout
> M->>ACS: AReq #1 (threeDSRequestorSpcSupport=Y)
> ACS->>AS: track (set amount, currency, payee)
> ACS->>AS: authentication-options (securePaymentConfirmation)
> AS-->>ACS: rpId, challenge, credentialIds, payment
> ACS-->>M: ARes #1 transStatus=S + SPC request data
> M->>B: Run PaymentRequest with SPC data
> B->>B: Cardholder approves with biometric
> B-->>M: Signed assertion (payment.get)
> M->>ACS: AReq #2 (threeDSReqAuthData, threeDSReqAuthMethod=09)
> ACS->>AS: verify (signature + transaction binding)
> AS-->>ACS: isVerified
> ACS-->>M: ARes #2 transStatus=Y + authentication value (CAVV/AAV) + ECI
> ```

For an ACS integration, lead with the **ACS-initiated** flow: it needs no merchant-side work and
keeps the ceremony, and the passkey relying party, entirely on the issuer side.

## Enrollment

Enroll **one payment passkey per user, per device**. It works across all of the cardholder's cards:
the specific card is a per-transaction value (see [step 1](#1-set-the-transaction-server-sdk)), not
bound to the credential. This matches the SPC model, where the relying party supplies the instrument
at authentication time and the issuer keeps the card-to-credential mapping.

Enrollment is owned by the ACS and typically offered as an uplift in a prior authenticated session
(for example right after a successful OTP step-up). Authsignal forces a platform authenticator with
required user verification and the `payment` extension, and marks the credential payment-capable.

**1. Generate an enrollment token (server SDK)**

```ts theme={null}
const { token } = await authsignal.track({ userId, action: "enroll-payment-passkey" });
```

Pass the `token` to the browser.

**2. Create the passkey (browser SDK)**

```ts theme={null}
await authsignal.passkey.signUp({ token, securePaymentConfirmation: true });
```

`paymentInstrument: { displayName }` is optional: it sets a default label for the single-card
case. Multi-card issuers omit it and set the card per transaction instead.

On a platform that can't enroll a payment credential, retry `signUp` without the flag to enroll a
standard passkey, or fall back to another method.

## Authenticating a payment

The ACS uses three Authsignal calls per transaction: set the transaction, fetch the SPC request
data, and verify the assertion. In the **ACS-initiated** flow the ACS makes all three server-side
and runs the ceremony in its iframe; in the **requestor-initiated** flow the SPC request data is
handed to the merchant in `ARes` and the assertion comes back in the second `AReq`.

Client REST calls use the Client API base `https://api.authsignal.com/v1/client` with
`Authorization: Bearer <token>` (regional bases available: `au.`, `eu.`, `ca.`). The `<token>` is the
action token returned by `track`.

### 1. Set the transaction (server SDK)

Set the amount, payee, and the card for this transaction on the action's `custom` data. These values
are the source of truth Authsignal validates the signed assertion against, and the client never
supplies them.

```ts theme={null}
const { token } = await authsignal.track({
  userId,
  action: "payment",
  attributes: {
    custom: {
      amount: "42.00", // decimal string
      currency: "USD", // ISO-4217
      payeeName: "Example Merchant",
      payeeOrigin: "https://merchant.example", // optional
      instrumentDisplayName: "Visa ••1234", // the card for this transaction
    },
  },
});
```

Pass the returned `token` to the client calls that follow.

### 2. Fetch the SPC request data (client REST)

Request authentication options with `securePaymentConfirmation: true`. The response carries the
`rpId`, `challenge`, the payment-capable `credentialIds`, and the server-set `payment`. The ACS uses
this to run the ceremony in its iframe, or delivers it to the merchant in `ARes`.

```bash theme={null}
curl -X POST https://api.authsignal.com/v1/client/user-authenticators/passkey/authentication-options \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  -d '{ "securePaymentConfirmation": true }'
```

```json theme={null}
{
  "challengeId": "26a003d6f3bd...",
  "options": {
    "rpId": "issuer.example.com",
    "challenge": "5LBBY_ASDe-O8mJfQ04qHg7ron1xQnTIgoFYcfAXYJE",
    "allowCredentials": [{ "id": "GkxnSxz3...", "type": "public-key", "transports": ["internal"] }],
    "timeout": 60000,
    "userVerification": "preferred"
  },
  "payment": {
    "amount": "42.00",
    "currency": "USD",
    "payeeName": "Example Merchant",
    "instrumentDisplayName": "Visa ••1234"
  }
}
```

`payment.instrumentDisplayName` is the card for this transaction (from `track`). Use it as the
`instrument` in the ceremony. `paymentInstruments` (a credential-level label) is still returned for
the single-card default, but the per-transaction value takes precedence.

> [!WARNING]
>
> `rpId`, `challenge`, `allowCredentials[].id` and `payment` are first-class fields in the response,
> not hidden implementation detail. Carry the `challengeId` across to the verify call in
> step 4.

### 3. Run the SPC ceremony (browser)

The page that runs the ceremony (the ACS challenge iframe, or the merchant page) builds a
`PaymentRequest` from the request data and shows the native **"Confirm your purchase"** dialog. The
amount and payee come from the server-set `payment`.

![Secure Payment Confirmation dialog showing Example Merchant, Visa ending 1234, and USD \$42.00](https://mintcdn.com/authsignal-23/mOSPI7mAWclqKr_K/images/docs/passkeys/spc-payment-dialog.svg?fit=max\&auto=format\&n=mOSPI7mAWclqKr_K\&q=85\&s=875fe55f6deee67b966428474bbb2f93)

The native Secure Payment Confirmation dialog. The browser renders the payee, instrument and amount, and binds them into the signed assertion.

```js theme={null}
const request = new PaymentRequest(
  [
    {
      supportedMethods: "secure-payment-confirmation",
      data: {
        rpId, // from authentication-options
        challenge: base64UrlToBuffer(challenge),
        credentialIds: credentialIds.map(base64UrlToBuffer),
        instrument: { displayName, icon }, // displayName from payment.instrumentDisplayName
        payeeName, // from `payment`
        timeout: 60000,
      },
    },
  ],
  { total: { label: "Total", amount: { currency, value: amount } } }, // from `payment`
);

const response = await request.show(); // cardholder approves with biometric
const credential = response.details; // a PublicKeyCredential with type "payment.get"
await response.complete("success");
// Serialize `credential` to JSON, this is the `authenticationCredential` for step 4.
```

The signed `clientDataJSON` (`type: "payment.get"`) binds the amount, currency and payee.

### 4. Verify the assertion (client REST)

The ACS verifies the assertion against the transaction set at `track`. Authsignal checks the WebAuthn
signature **and** that the signed `payment` matches the server-set amount, currency and payee, so a
tampered client can't bind a valid signature to a different transaction.

```bash theme={null}
curl -X POST https://api.authsignal.com/v1/client/verify/passkey \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  -d '{ "challengeId": "{challengeId}", "authenticationCredential": { /* from the browser */ } }'
# → { "isVerified": true, "token": "..." }
```

A mismatch (wrong amount or payee) or an invalid signature is rejected here with `401`.

### 5. Confirm the result (server SDK)

```ts theme={null}
const { state } = await authsignal.validateChallenge({ token });
// state === "CHALLENGE_SUCCEEDED"
```

The ACS then computes the authentication value (CAVV/AAV) and ECI and returns the 3DS result.

## EMV 3DS field mapping

How the Authsignal steps sit in each flow. Authsignal's calls are the same; the 3DS message carriage
differs.

| Authsignal step | ACS-initiated | Requestor-initiated |
| - | - | - |
| Advertise SPC support | `acsInfoInd` = `07` | `acsInfoInd` = `07`; requestor sends `threeDSRequestorSpcSupport` = `Y` in `AReq` |
| Fetch SPC request data (2) | Used internally by the ACS | Returned in `ARes` with `transStatus` = `S` (`webAuthnCredentialList`, `challenge`, payee, amount) |
| Run ceremony (3) | ACS challenge iframe (`CReq`/`CRes`) | Merchant page `PaymentRequest` |
| Return assertion | Within the ACS challenge | Second `AReq`: `threeDSReqAuthData`, `threeDSReqAuthMethod` = `09`, prior `05` |
| Verify (4) and status | `ARes` `transStatus` = `C`, `authenticationMethod` = `14`; `CRes` on completion | Final `ARes` `transStatus` = `Y` |
| Ceremony not completed | `SPCIncompletionIndicator` set; ACS falls back (OTP/OOB) | `SPCIncompletionIndicator` set; ACS falls back |

Field names follow the EMV 3DS 2.3.1 specification. Confirm the exact carriage of the SPC request
data and assertion against your 3DS Server and scheme profile.

## Feature detection and fallback

SPC only runs on **Chromium browsers (Chrome on macOS, Windows, and Android)**. On Safari and Firefox
the ACS must fall back, and must never dead-end the cardholder. Detect support before attempting the
ceremony:

```js theme={null}
const available =
  "PaymentRequest" in window &&
  !!PaymentRequest.securePaymentConfirmationAvailability &&
  (await PaymentRequest.securePaymentConfirmationAvailability()) === "available";

if (!available) {
  // Fall back: standard passkey, then another method (e.g. OTP or OOB).
}
```

The recommended ladder for a 3DS step-up is **SPC → standard passkey → OTP/OOB**, and you can offer
SPC enrollment as an uplift after the fallback succeeds.

## First-party alternative (browser SDK)

For a merchant-initiated, first-party web flow (not split across 3DS messages), the
[`@authsignal/browser`](https://docs.authsignal.com/sdks/client/web/passkeys) SDK wraps the client calls in steps 2 to 4 into
one method:

```ts theme={null}
const { data } = await authsignal.passkey.signInWithPaymentConfirmation({ token });
```

The amount and payee still come from the server (set at `track`), and there is no `payment`
argument. For a split 3DS ceremony, the SDK also exposes `runPaymentConfirmation({ rpId, challenge,
credentialIds, payment })`, which runs **only** the browser ceremony against request data you already
hold.

> [!NOTE]
>
> Authsignal never stores or sees the card number; the instrument is a display label and icon only.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.