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

# How-to: Implement Strong Customer Authentication

> Register your application origin, enroll end-user passkeys, and complete SCA-protected operations using the challenge–assertion flow.

Strong Customer Authentication (SCA) uses passkeys to protect sensitive
operations. End users must approve each operation before Circle processes it.
Each approval is bound to a specific operation and cannot be reused.

<Note>
  SCA applies to end users onboarded under Circle's EU-regulated entity. This is
  based on the end user's legal entity, not your platform's. Contact your
  support team if you are unsure.
</Note>

For an overview of how SCA works, see
[Strong Customer Authentication](/digital-asset-accounts/concepts/strong-customer-authentication).

<Note>
  The SCA ceremony runs in the browser. There is no iOS, Android, or React
  Native SDK for it yet—native applications must host the ceremony in a web view
  or the system browser.
</Note>

## Prerequisites

Before you begin, ensure that you've:

* Registered your application origin with Circle. Reach out to your support team
  with the exact origin (scheme + host, for example `https://app.example.com`).
  Registration is required separately for sandbox and production.
* Installed the [DAA web SDK](/sdks/daa/web-sdk).
* Obtained a `clientEntityId` for the end user you're enrolling. See
  [Onboard customers](/digital-asset-accounts/quickstarts/onboard-customers) for
  how to create one.
* Run a device check for the end user and stored the `deviceId` it returned. See
  [Collect device risk signals](/end-user-onboarding/howtos/collect-device-risk-signals).
  Every protected operation below sends that `deviceId` in `riskSignals`.

<Warning>
  Reuse the `deviceId` returned by `checkDevice()`. Do not generate one. Circle
  resolves it against the completed device check, and an unrecognized value is
  **accepted and then declined**: the endpoint returns `201` and the transaction
  later settles as `failed`.
</Warning>

<Note>
  The Digital Asset Accounts API base URL is `https://api-sandbox.circle.com` for
  sandbox and `https://api.circle.com` for production. Set your API key in the
  `Authorization` header using the format `Bearer YOUR_API_KEY`. See
  [Sandbox environment](/digital-asset-accounts/references/testing-in-sandbox) and
  [Going to production](/digital-asset-accounts/references/going-to-production)
  for environment details.
</Note>

## Steps

### Step 1. Enroll a passkey

Enroll a passkey for each end user before they run any protected operation. Run
this flow once per end user.

<Warning>
  If your application origin is not registered, the enrollment ceremony appears
  to succeed—the passkey is created and the SDK returns normally—but the
  subsequent registration call silently times out and returns a generic Expired
  error after 5 minutes. Register your origin before running this flow.
</Warning>

#### Step 1.1. Create a registration session

```shell theme={null}
curl --request POST \
  --url https://api-sandbox.circle.com/v1/accounts/passkeys/registrations \
  --header 'Accept: application/json' \
  --header 'Authorization: Bearer ${YOUR_API_KEY}' \
  --header 'Content-Type: application/json' \
  --data '
{
  "clientEntityId": "${CLIENT_ENTITY_ID}",
  "idempotencyKey": "${RANDOM_UUID}"
}
'
```

**Response**

```json theme={null}
{
  "data": {
    "registrationId": "a1b2c3d4-e5f6-7890-ab12-cdef34567890",
    "frameToken": "k74ia-AcnTzXtBdxnbVqn1IBpVUXBbhGiGxQkGD386A",
    "expiresAt": "2026-09-24T12:10:00Z"
  }
}
```

<Note>
  The frame token expires after 10 minutes. Pass it to your frontend right away.
  Do not re-send the request with the same `idempotencyKey` after delivering the
  token—this rotates the token and invalidates the copy you already sent. Start
  a fresh session with a new `idempotencyKey` if the token expires.
</Note>

#### Step 1.2. Run the enrollment ceremony

Initialize the SDK on your frontend and call `sca.enroll` with the frame token
from step 1.1.

```typescript theme={null}
import { createScaClient } from "@circle-fin/daa-web-sdk";

const sca = createScaClient({ environment: "sandbox" }); // or 'production'

const attestationResponse = await sca.enroll(frameToken, {
  mode: "modal", // or 'inline' with a container element
});

// Forward attestationResponse to your backend unchanged — do not re-serialize
```

<Note>
  The SDK renders the passkey prompt in a Circle-hosted iframe. Pass
  `attestationResponse` to your backend as-is. Re-serializing it causes a
  verification error.
</Note>

#### Step 1.3. complete registration

Send the `attestationResponse` from the SDK to your backend. Then forward it to
Circle to complete enrollment.

```shell theme={null}
curl --request POST \
  --url https://api-sandbox.circle.com/v1/accounts/passkeys \
  --header 'Accept: application/json' \
  --header 'Authorization: Bearer ${YOUR_API_KEY}' \
  --header 'Content-Type: application/json' \
  --data '
{
  "registrationId": "${REGISTRATION_ID}",
  "attestationResponse": ${ATTESTATION_RESPONSE}
}
'
```

**Response**

```json theme={null}
{
  "data": {
    "passkeyId": "a1b2c3d4-e5f6-7890-ab12-cdef34567890",
    "credentialId": "dGhpcyBpcyBhIGJhc2U2NHVybCBleGFtcGxl"
  }
}
```

### Step 2. Approve a protected operation

For every protected operation, obtain a signed challenge and include it in the
API request.

#### Step 2.1. Create a challenge

```shell theme={null}
curl --request POST \
  --url https://api-sandbox.circle.com/v1/accounts/passkeys/challenges \
  --header 'Accept: application/json' \
  --header 'Authorization: Bearer ${YOUR_API_KEY}' \
  --header 'Content-Type: application/json' \
  --data '
{
  "clientEntityId": "${CLIENT_ENTITY_ID}",
  "operation": "TRANSFER",
  "intent": {
    "idempotencyKey": "${RANDOM_UUID}",
    "source": {
      "type": "account",
      "id": "${ACCOUNT_ID}"
    },
    "destination": {
      "type": "verified_blockchain",
      "addressId": "${RECIPIENT_ADDRESS_ID}"
    },
    "amount": {
      "amount": "50.00",
      "currency": "USD"
    },
    "riskSignals": {
      "ipAddress": "${END_USER_IP}",
      "sessionId": "${SESSION_ID}",
      "deviceId": "${DEVICE_ID}"
    }
  }
}
'
```

**Response**

```json theme={null}
{
  "data": {
    "challengeId": "b2c3d4e5-f6a7-8901-bc23-def456789012",
    "frameToken": "k74ia-AcnTzXtBdxnbVqn1IBpVUXBbhGiGxQkGD386A",
    "expiresAt": "2026-09-24T12:05:00Z",
    "summary": {
      "type": "transfer",
      "amount": { "amount": "50.00", "currency": "USD" },
      "destination": "1001587127",
      "sourceAccountLabel": "My DAA account"
    }
  }
}
```

<Note>
  The `summary` is a server-generated record of the operation. Do not use it to
  render a pre-confirmation to the end user—the Circle iframe shows its own
  description to the user.
</Note>

<Note>
  The frame token expires after 5 minutes. Pass it to your frontend right away.
  Re-sending with the same `idempotencyKey` rotates the token and invalidates
  the copy you already sent. Create a new challenge with a fresh
  `idempotencyKey` if the token expires.
</Note>

The intent must be identical to the operation request body—same fields, same
values. An extra or missing field causes an intent mismatch error (420047). For
the full list of intent shapes by operation, see the
[Create a challenge API reference](/api-reference/digital-asset-accounts/all/open-passkey-challenge).

<Warning>
  The `idempotencyKey` in the intent must match the `idempotencyKey` in the
  operation request body. A mismatch causes an intent mismatch error.
</Warning>

#### Step 2.2. Run the approval ceremony

Pass the `frameToken` from the challenge response to the SDK's `approve` method.
The SDK renders the passkey prompt and returns a signed assertion string.

```typescript theme={null}
import { createScaClient } from "@circle-fin/daa-web-sdk";

const sca = createScaClient({ environment: "sandbox" }); // or 'production'

const assertion = await sca.approve(frameToken, { mode: "modal" });

// Forward assertion to your backend as a string — do not re-serialize
```

#### Step 2.3. Submit the protected operation

Include the `challengeId` and `assertion` as request headers. Use the same field
values you set in the challenge intent.

```shell theme={null}
curl --request POST \
  --url https://api-sandbox.circle.com/v1/accounts/transfers \
  --header 'Accept: application/json' \
  --header 'Authorization: Bearer ${YOUR_API_KEY}' \
  --header 'Content-Type: application/json' \
  --header 'X-Sca-Challenge-Id: ${CHALLENGE_ID}' \
  --header 'X-Sca-Assertion: ${ASSERTION}' \
  --data '
{
  "idempotencyKey": "${RANDOM_UUID}",
  "source": {
    "type": "account",
    "id": "${ACCOUNT_ID}"
  },
  "destination": {
    "type": "verified_blockchain",
    "addressId": "${RECIPIENT_ADDRESS_ID}"
  },
  "amount": {
    "amount": "50.00",
    "currency": "USD"
  },
  "riskSignals": {
    "ipAddress": "${END_USER_IP}",
    "sessionId": "${SESSION_ID}",
    "deviceId": "${DEVICE_ID}"
  }
}
'
```

**Response**

```json theme={null}
{
  "data": {
    "id": "e1f2a3b4-c5d6-7890-ef12-3456789abcde",
    "status": "pending",
    "amount": {
      "amount": "50.00",
      "currency": "USD"
    },
    "source": {
      "type": "account",
      "id": "1017381855"
    },
    "destination": {
      "type": "verified_blockchain",
      "id": "c7d8e9f0-a1b2-3456-7890-abcdef123456"
    },
    "createDate": "2026-09-24T12:00:00Z",
    "updateDate": "2026-09-24T12:00:00Z"
  }
}
```

<Warning>
  `201` means the operation was accepted, not that it succeeded. A transfer is
  screened after it is created and can settle as `failed`. Poll `GET
      /v1/accounts/transfers/{id}` until `status` is terminal, and read `errorCode`
  and `riskEvaluation` on that response to see why—for example `errorCode:
      transfer_denied` with `riskEvaluation.decision: denied`. The list endpoint
  omits `riskEvaluation`, so fetch the transfer by id. A declined device check
  or an unrecognized `deviceId` surfaces here, not on the create call.
</Warning>

If the request was rejected before the transfer was created, the endpoint
returns an error instead of 201.

<Note>
  If a protected endpoint returns HTTP 428 (error code 420058), the
  `X-Sca-Challenge-Id` or `X-Sca-Assertion` header was omitted from the request.
  Run the approval ceremony and retry with both headers present. For other SCA
  errors, see the [Digital Asset Accounts API
  reference](/api-reference/digital-asset-accounts).
</Note>
