
# Register a passkey

`POST /fapi/v1/me/passkeys` · called from the environment's sign-in host

Finishes a registration started with [`POST /fapi/v1/me/passkeys/registration-options`](./registration-options): verifies the browser's answer, stores the passkey or security key, and returns it. The user receives a security email about the new sign-in method; this email cannot be turned off. Requires the authenticated user's `__Host-idp_session` cookie; if the cookie is missing, expired, or revoked, returns `401 unauthenticated`. A first-party app on another origin can instead send its front API access token (`Authorization: DPoP <token>` with a DPoP proof; see [Front API access token](/docs/api/fapi/front-api-token)).

The session rules are those of the start call: verified in the last 10 minutes, and opened with a second factor if the user has one.

## Request

Headers:

| Header | Required | Description |
| --- | --- | --- |
| `Idp-Client` | yes | SDK identifier. |
| `Origin` | yes | Must be exactly `https://<sign-in host>`. |
| `Content-Type` | yes | `application/json`. |

Body:

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `ceremony_id` | string | yes | The `ceremony_id` of the start call. |
| `credential` | object | yes | The result of `navigator.credentials.create` as JSON (`PublicKeyCredential.toJSON()`), at most 16 KiB. |

## Response

`201 Created`, the new authenticator:

```json
{
  "id": "cred_01j9zzzzzzzzzzzzzzzzzzzzzz",
  "type": "passkey",
  "label": "iCloud Keychain",
  "status": "active",
  "synced": true,
  "created_at": "2026-10-12T09:00:00Z",
  "last_used_at": null
}
```

## Errors

A refused registration is not counted as a failed attempt; start again with a new registration.

| Status | Code | When |
| --- | --- | --- |
| 400 | `malformed_request` | The body is not valid JSON. |
| 400 | `unknown_field` | The body has a field other than `ceremony_id` and `credential`. |
| 401 | `unauthenticated` | No valid session cookie. |
| 401 | `step_up_required` | The session is too old or a second factor is required; re-verify with the values in the `WWW-Authenticate` header. |
| 403 | `origin_not_allowed` | `Origin` is missing or wrong. |
| 409 | `credential_already_registered` | This authenticator is already registered in the environment. |
| 409 | `credential_limit_reached` | The user already has the maximum number of passkeys and security keys. |
| 413 | `payload_too_large` | The body is too large. |
| 415 | `unsupported_media_type` | `Content-Type` is not `application/json`. |
| 422 | `validation_failed` | `/ceremony_id`: the registration is unknown, expired, already used or another session's. `/credential`: the answer is missing or does not verify. |
| 422 | `device_bound_passkey_required` | The environment accepts only passkeys that stay on one device. |
| 422 | `aaguid_not_allowed` | The environment does not allow this authenticator model. |
| 422 | `attestation_untrusted` | The environment requires a trusted attestation and this one is not. |

## Example

```bash
curl -X POST https://example.keenauth.app/fapi/v1/me/passkeys \
  -H "Idp-Client: my-app/1.0.0 (node/22.0.0)" \
  -H "Origin: https://example.keenauth.app" \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{"ceremony_id": "CQAAAAAAAAAAAAAAAAAAAA", "credential": {"id": "…", "rawId": "…", "type": "public-key", "response": {"clientDataJSON": "…", "attestationObject": "…"}}}'
```

## Personal data

This endpoint carries personal data: credentials. [Personal data in the KeenAuth APIs](/docs/policies/personal-data) describes what is stored, how long it is kept and how it is deleted. Do not send special categories of personal data (health, biometric data, religion, political opinions, sexual orientation) in any field.
