# Passkeys & Step-Up

A passkey — kept by the user's device or password manager, unlocked with a PIN or a biometric — confirms one sensitive action at a time: an approval's decision, a policy set going live, a change to one's own passkeys. That confirmation is the step-up.

## Overview

- **For every role**: `owner`, `tenantadmin`, `non_signing_admin`, `signer`, `approver`, `editor`, `viewer`, `auditor`, `security_admin` and `security_auditor` ([Roles](https://docs.aureahub.com/docs/authentication.md)). The End User (`user`) too: it confirms its own money out of the bank ramp with a passkey (Where It Is Asked).
- **Not a sign-in.** A passkey signs no one in: the password — and the second factor where it is asked ([Two-Factor Sign-In](https://docs.aureahub.com/docs/guide-two-factor.md)) — still do. Once signed in, a passkey confirms one action on one target, and nothing else.
- **Made and used on one web address.** A passkey belongs to the relying party it was registered for — a web domain — and the API checks every answer of an authenticator against that relying party and its web addresses. By default it is Aurea's own, `access.aureahub.com`: your administrators and approvers register their passkeys, and confirm their actions, on Aurea's web address. Your tenant can also have its own passkey address [Coming soon, upon request](https://docs.aureahub.com/docs/feature-availability.md), for your own apps (Your Own Passkey Address, below). A passkey is accepted only for the relying party it was registered for, and an authenticator's answer made on another web address is refused (`PASSKEY_REGISTRATION_INVALID`, `STEP_UP_INVALID`).
- **Each user manages their own** with their access token (the routes under **Passkeys** in the API Reference); an administrator resets another user's (A Lost Device).
- Written to your tenant's [audit trail](https://docs.aureahub.com/docs/audit-events.md) [Coming soon, upon request](https://docs.aureahub.com/docs/feature-availability.md) (category `access`): `passkey.registered`, `passkey.revoked`, `passkey.reset`, `step_up.verified` and `step_up.used`; and an approval decided with a passkey names it (`passkeyId`) in `approval.approved` or `approval.rejected`. Never a credential, a challenge, a signature or a token.

## Availability

Passkeys are on for every tenant, on Aurea's relying party: the relying party id `access.aureahub.com`, used from `https://access.aureahub.com`. [List Your Passkeys](https://docs.aureahub.com/docs/passkeys-list.md) answers `stepUp.available` `true`. Your tenant's own passkey address, for your own apps, is on request (Your Own Passkey Address).

Whether an action **requires** a step-up is your tenant's step-up mode — `stepUp.mode` in List Your Passkeys:

| mode | An action that asks for a step-up (Where It Is Asked) |
| --- | --- |
| log | **Every tenant today.** A step-up sent with the action is used and recorded; an action sent without one, or with one that is not for it, goes on as before. Nothing is refused. |
| enforce | **Required.** Without a step-up: `403` `STEP_UP_REQUIRED`; with one that is not for this action: `403` `STEP_UP_INVALID`. On request. A tenant on the institutional security profile is in `enforce` by its profile. |

- Have your administrators register their passkeys while your tenant is in `log`, before `enforce`.
- An approval's decision does not depend on the mode: it is always proven outside the session (Approvals).

## Your Own Passkey Address

[Coming soon, upon request](https://docs.aureahub.com/docs/feature-availability.md) Your tenant can have its own passkey address beside Aurea's: a relying party id (RP ID) — a domain of yours — and the https web addresses (origins) of your apps that use it. Aurea sets it up for your tenant on request. Your administrators and approvers can then make and use their passkeys in your own apps — an approvals app, your back office.

- **Read it** with [Your Passkey Address](https://docs.aureahub.com/docs/passkey-address.md): `rpId` and `origins`, `rpId` `null` while your tenant has none.
- **Name it in the options' requests**: `rpId` in [Registration Options](https://docs.aureahub.com/docs/passkey-registration-options.md) and [Step-Up Options](https://docs.aureahub.com/docs/step-up-options.md). Without `rpId` the options are Aurea's own, exactly as before. An `rpId` that is neither Aurea's nor your tenant's answers `400` `PASSKEY_RP_NOT_ALLOWED`.
- **The RP ID and its origins.** The RP ID is a domain name of two labels or more, such as `example.com`. Each origin is `https://` and a host — the RP ID or one of its subdomains, such as `https://approvals.example.com` — with an optional port and nothing after it; one to five of them. The authenticator shows your tenant's name as the relying party's name.
- **A passkey keeps its relying party.** It is asked for, and verified, on the relying party it was registered for only: Step-Up Options lists the user's passkeys of the relying party it names (none there: `409` `STEP_UP_NO_PASSKEY`), and an assertion by a passkey of the other relying party answers `403` `STEP_UP_INVALID`, even when it is made on one of your origins. A user may hold passkeys on both: the limit of 10 counts them together, and the step-up that confirms a next passkey may come from a passkey on either.
- **https origins only, for now.** Your passkey address lists the https origins your apps use — you give them to Aurea with the RP ID —, and an authenticator's answer is accepted only from one of them. A web app on your domain uses its own address; a native app follows its platform's own passkey rules for which origin its answers carry, so check that origin against your list. Origins that are not https addresses — such as an Android app's — are not accepted yet: ask your Aurea contact.
- **Through your server.** Your passkey address does not open Aurea's API to browser calls from your web address: its cross-origin rules are unchanged. Your web app runs the browser's part — `navigator.credentials.create()` and `get()` — and passes the options and the authenticator's answers through your server, which calls the passkey routes with the user's access token.
- **Aurea's own relying party goes on**: the passkeys made there keep working there. If your address is removed (on request), its passkeys and its open options are refused from then on, and Aurea's go on.

```javascript
// On your server, with the signed-in user's access token
const address = await fetch(`https://api.aureahub.com/v1/admin/tenants/${tenantId}/passkey-address`, {
  headers: { Authorization: `Bearer ${accessToken}` },
}).then((r) => r.json());
// address.rpId: 'example.com' — or null: your tenant has no address of its own

// The options for your own relying party: hand options.publicKey to your web app
const options = await post('/v1/auth/passkeys/registration/options', { rpId: address.rpId });
// … and for a step-up: post('/v1/auth/step-up/options', { action: 'approval.approve', operationId, rpId: address.rpId })
```

## Register a Passkey

1. [Registration Options](https://docs.aureahub.com/docs/passkey-registration-options.md) — with `rpId` for your own passkey address, or no body for Aurea's: `publicKey` — the options for `navigator.credentials.create()`, in WebAuthn's JSON form —, `ceremonyId`, `expiresAt` (5 minutes) and `stepUpAction`.
2. The browser makes the passkey, with the user's PIN or biometric.
3. [Register a Passkey](https://docs.aureahub.com/docs/passkey-register.md) with `ceremonyId`, a `name` of 1 to 100 characters, and `credential`: the new credential's `toJSON()`. `201` with the passkey.

```javascript
const auth = { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' };
const post = (path, body = {}) => fetch(`https://api.aureahub.com${path}`, {
  method: 'POST', headers: auth, body: JSON.stringify(body),
}).then(async (r) => { const json = await r.json(); if (!r.ok) throw json; return json; });

// 1. The options
const options = await post('/v1/auth/passkeys/registration/options');

// 2. The browser makes the passkey (a PIN or a biometric)
const credential = await navigator.credentials.create({
  publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options.publicKey),
});

// 3. Not the first passkey: confirm it with one the user already has (The Step-Up)
const stepUpToken = options.stepUpAction ? await stepUp({ action: 'passkey.register' }) : undefined;

// 4. Register it
const passkey = await post('/v1/auth/passkeys', {
  ceremonyId: options.ceremonyId,
  name: 'Work laptop',
  credential: credential.toJSON(),
  stepUpToken,
});
```

- **What is asked of the authenticator**: user verification — a PIN or a biometric — on every use; no attestation (any authenticator, whatever its make); ES256 or RS256; a discoverable passkey preferred. The passkey's user handle is the user's id; the authenticator shows the username (or the email) as its name. Where the browser has no `parseCreationOptionsFromJSON()`, decode `challenge`, `user.id` and each `excludeCredentials[].id` from base64url yourself.
- **The options are used once, within 5 minutes**: after that, or once a passkey was registered with them, `400` `PASSKEY_CHALLENGE_INVALID` — ask for new ones.
- **A passkey is registered once**: the same credential again — for this account or any other — answers `409` `PASSKEY_IN_USE`. The options list the user's active passkeys (`excludeCredentials`), so the browser does not make a second one on the same authenticator.
- **At most 10 active passkeys per user**: `409` `PASSKEY_LIMIT` — revoke one first.
- **The first passkey needs only the session.** Each next one is confirmed with a step-up of a passkey the user already has — `stepUpAction` reads `passkey.register` —: where the step-up is enforced, without it `403` `STEP_UP_REQUIRED`. A stolen session cannot add a passkey of its own there. After an administrator's reset, the next passkey is a first one again.

## The Step-Up

1. [Step-Up Options](https://docs.aureahub.com/docs/step-up-options.md) with the `action` and its target — and `rpId` for your own passkey address: `publicKey`, the options for `navigator.credentials.get()`, and `ceremonyId` (5 minutes, once).
2. The browser asks one of the user's active passkeys, with their PIN or biometric.
3. [Step-Up](https://docs.aureahub.com/docs/step-up.md) with `ceremonyId` and the assertion's `toJSON()`: `stepUpToken`, `action` and `expiresAt`.
4. Send `stepUpToken` with the action, within 2 minutes.

| action | Its target in the body | Who |
| --- | --- | --- |
| approval.approve approval.reject | `operationId`: the operation's newest approval request, and its payload for that decision | `owner`, `tenantadmin`, `non_signing_admin`, `approver` |
| policy_set.publish policy_set.rollback | `setId`: the set published, or the archived version rolled back to | `owner`, `tenantadmin`, `non_signing_admin`, `security_admin` |
| passkey.register | nothing | anyone with a passkey, for their own next one |
| passkey.revoke | `passkeyId`: the passkey revoked | anyone with a passkey, for their own |

```javascript
// One step-up: one action on one target, confirmed by one of the user's passkeys
async function stepUp(ask) { // e.g. { action: 'approval.approve', operationId }
  const options = await post('/v1/auth/step-up/options', ask);
  const assertion = await navigator.credentials.get({
    publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options.publicKey),
  });
  const { stepUpToken } = await post('/v1/auth/step-up', {
    ceremonyId: options.ceremonyId,
    credential: assertion.toJSON(),
  });
  return stepUpToken; // 2 minutes, once, for this action on this target only
}
```

- **The challenge commits to the action.** The API derives it from the action, the tenant, the target and a random value, and the passkey signs it: the assertion itself says what it confirmed.
- **The assertion is verified** with the user's passkey it names, which must be active and of the relying party the options named: the web address, user verification, the signature, and the passkey's counter — a replayed assertion is refused. Anything wrong — options unknown, used, expired or another user's, another passkey, a signature that does not verify — answers `403` `STEP_UP_INVALID`, one answer for every reason: ask for new options.
- **The token** lives 2 minutes and is used once, by the user who made it, for that action on that target in that tenant — and only while its passkey is active. Keep it in memory: it is answered with `Cache-Control: no-store`, and Aurea keeps only its hash.
- **An approval's options are bound to the request as it is now.** If the operation is held again with a new request, ask for new options. A policy set's options are given for any `setId`: the publication itself checks the set.

## Where It Is Asked

| Route | The step-up | When |
| --- | --- | --- |
| [POST /v1/policy-sets/{setId}/publish](https://docs.aureahub.com/docs/policy-set-publish.md) | `policy_set.publish` on that set | your tenant's mode |
| [POST /v1/policy-sets/{setId}/rollback](https://docs.aureahub.com/docs/policy-set-rollback.md) | `policy_set.rollback` on the archived version | your tenant's mode |
| [POST /v1/auth/passkeys](https://docs.aureahub.com/docs/passkey-register.md) — a next passkey | `passkey.register` | your tenant's mode |
| [POST /v1/auth/passkeys/{passkeyId}/revoke](https://docs.aureahub.com/docs/passkey-revoke.md) | `passkey.revoke` on that passkey | your tenant's mode |
| [POST /v1/approvals/{operationId}/approve](https://docs.aureahub.com/docs/approval-approve.md), […/reject](https://docs.aureahub.com/docs/approval-reject.md) | `approval.approve`, `approval.reject` on that operation — or an approval key's signature | **always** (Approvals) |
| [POST /v1/mpc/wallets/{walletId}/sign/start](https://docs.aureahub.com/docs/mpc-sign-start.md) — committee `BACKUP` | the owner's current password (`password`), or a passkey step-up for that wallet's recovery signature (`stepUpToken`, [Recovery Step-Up](https://docs.aureahub.com/docs/mpc-step-up.md)) | **always** |
| [POST /v1/ramp/bank/payout/payouts](https://docs.aureahub.com/docs/payout-wallet-pay.md), […/customer/offramps](https://docs.aureahub.com/docs/local-offramp-create.md), […/customer/withdrawals](https://docs.aureahub.com/docs/local-withdrawal-create.md), […/customer/desk/orders/{id}/confirm](https://docs.aureahub.com/docs/desk-confirm.md) (to a bank account) | `bank_ramp.payout`, in the `x-aurea-step-up` header | **always** |
| [POST /v1/ramp/bank/payin/initiate](https://docs.aureahub.com/docs/payin-initiate.md) — an account moved to another address | `bank_ramp.destination_change`, in the `x-aurea-step-up` header | **always** |

The rows marked **always** are required for every tenant: without the step-up `403` `STEP_UP_REQUIRED`, with one that is not for this action `403` `STEP_UP_INVALID`, and nothing is done. The bank ramp's money out takes its token in the `x-aurea-step-up` header of the request it confirms — one token, one request, within 2 minutes; your app's End User registers a passkey first (Register a Passkey).

The routes that follow your tenant's mode take `stepUpToken` in their body:

| The request carries | log | enforce |
| --- | --- | --- |
| No `stepUpToken` | Goes on. | `403` `STEP_UP_REQUIRED`, with `details.action` and `details.tenantId`. |
| The token for this action on this target | Used, once; goes on. | The same. |
| Another token — unknown, expired, used, another user's, for another action or target, or its passkey revoked since | Goes on. | `403` `STEP_UP_INVALID`. |

```json
{
  "statusCode": 403,
  "error": "ForbiddenError",
  "message": "Confirm this with your passkey: ask POST /v1/auth/step-up/options for this action, then send its stepUpToken",
  "details": {
    "code": "STEP_UP_REQUIRED",
    "action": "policy_set.publish",
    "tenantId": "6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0"
  }
}
```

- **On `STEP_UP_REQUIRED`**, run the step-up for `details.action` on the same target and send the request again with its `stepUpToken`. Nothing was done.
- **A policy set**: the step-up is checked after the publication's own checks — `POLICY_SET_NOT_FOUND`, `POLICY_SET_NOT_AWAITING`, `POLICY_QUORUM_AUTHOR`; for a rollback `POLICY_SET_NOT_ARCHIVED`, `POLICY_SET_NEVER_ACTIVE` —, and its token is used with the publication, or not at all. A rollback that waits for another administrator ([Who Publishes](https://docs.aureahub.com/docs/guide-policy-sets.md)) is confirmed again when that administrator publishes it: their own `policy_set.publish` step-up on the new version.

## Approvals

- **A decision is never taken on the session alone, in every mode.** [Approve](https://docs.aureahub.com/docs/approval-approve.md) and [Reject](https://docs.aureahub.com/docs/approval-reject.md) carry either `signature` — an approval key's ([Sign a Decision](https://docs.aureahub.com/docs/guide-approvals.md)), for approvers who decide from a server or a device of their own — or `stepUpToken`, a passkey's step-up for `approval.approve` or `approval.reject` on that operation, for approvers who confirm with a passkey. Both, or neither: `400` `APPROVAL_SIGNATURE_MALFORMED`.
- **The token must be this approver's, for this request, its payload and this decision**: anything else answers `403` `STEP_UP_INVALID`, nothing recorded.
- **Both count toward the rule's threshold**: a rule that asks for two approvals is met by one approver's key and another's passkey.
- An approval key's signature counts the same where the step-up is enforced: the key is outside the session, and it signs that one request.

## Your Own Passkeys

- [List Your Passkeys](https://docs.aureahub.com/docs/passkeys-list.md): newest first, revoked ones included — `deviceType` (`multiDevice`: a synced passkey, in a password manager or a platform's keychain; `singleDevice`: bound to one authenticator), `backedUp`, `lastUsedAt` (the last step-up it confirmed) — and `stepUp`: your tenant's `mode` and whether passkeys are `available`.
- [Revoke a Passkey](https://docs.aureahub.com/docs/passkey-revoke.md), for good, with an optional `reason`: where the step-up is enforced, confirmed with a `passkey.revoke` step-up on that passkey, by any of your active passkeys — the one revoked included. A stolen session cannot take your passkeys away there. A token a revoked passkey made and not yet used is refused from then on.

## A Lost Device

- **With another passkey**: revoke the lost one, confirming with the passkey you still have.
- **With none left**: another administrator of your tenant revokes all the user's active passkeys with [Reset a User's Passkeys](https://docs.aureahub.com/docs/passkeys-reset.md) — never their own (`403` `PASSKEY_RESET_SELF`) —, under the rules of every change to another user's account ([Roles](https://docs.aureahub.com/docs/authentication.md)). The user then registers a new passkey — a first one again, with the session alone. Where your tenant's quorum holds administrators' actions, the reset waits for another administrator ([Your Tenant's Quorum](https://docs.aureahub.com/docs/guide-tenant-quorum.md)); a reset that can only be refused — an account you may not act on, a user who doesn't exist — is refused at once, never held.

## Refusals

The usual envelope, the code in `details.code`; never a credential, a challenge, a signature or a token:

| Status | details.code | message |
| --- | --- | --- |
| 400 | PASSKEY_NAME_INVALID | `A passkey's name is 1 to 100 characters` — also a name of spaces only |
| 400 | PASSKEY_CHALLENGE_INVALID | `This registration's challenge is unknown, used or expired: ask for new options` |
| 400 | PASSKEY_REGISTRATION_INVALID | `The authenticator's answer did not verify for this registration` — another web address, another challenge, no user verification |
| 400 | PASSKEY_REASON_INVALID | `A reason is 1 to 500 characters` |
| 400 | PASSKEY_RP_NOT_ALLOWED | `rpId is neither the platform's relying party nor your tenant's own passkey address` — an `rpId` in the options' request that is neither Aurea's nor your tenant's |
| 400 | STEP_UP_ACTION_INVALID | `This action needs operationId (a UUID)` — or `setId`, `passkeyId`: the action's target is missing |
| 403 | PASSKEY_RESET_SELF | `An administrator's reset does not revoke one's own passkeys: revoke each with POST /v1/auth/passkeys/{passkeyId}/revoke` — Reset a User's Passkeys on one's own account |
| 403 | STEP_UP_ACTION_FORBIDDEN | `You may not <action> here`, the action in words (e.g. `You may not policy set publish here`), with `details.action` — a role that does not take that action |
| 403 | STEP_UP_REQUIRED | `Confirm this with your passkey: ask POST /v1/auth/step-up/options for this action, then send its stepUpToken`, with `details.action` and `details.tenantId` — where the step-up is enforced |
| 403 | STEP_UP_INVALID | At an action: `This step-up token is not valid for this action: it is unknown, expired, used, or for something else`, with `details.action` and `details.tenantId`. At Step-Up: `The passkey's answer did not verify for this step-up: ask for a new one` |
| 404 | PASSKEY_NOT_FOUND | `No such passkey of yours` |
| 404 | STEP_UP_TARGET_NOT_FOUND | `No approval request for this operation here` |
| 409 | PASSKEY_IN_USE | `This passkey is registered already: a passkey is registered once` |
| 409 | PASSKEY_LIMIT | `At most 10 active passkeys: revoke one first` |
| 409 | PASSKEY_REVOKED | `This passkey is revoked already` |
| 409 | STEP_UP_NO_PASSKEY | `You have no passkey to confirm with: register one first` |
| 503 | PASSKEYS_NOT_CONFIGURED | `Passkeys are not configured on this platform` — no relying party is set up: not answered in production, where Aurea's is |

- A body that does not fit the route's schema — an unknown `action`, an id that is not a UUID, a missing `ceremonyId` — answers `400` `Request validation failed`. A frozen account or tenant: `423` ([Freezes & Quarantines](https://docs.aureahub.com/docs/guide-freezes.md)).
- Rate limits: 20 requests per minute per client IP on Registration Options, Register a Passkey, Revoke a Passkey, Step-Up Options and Step-Up.

---

Web version: https://docs.aureahub.com/#guide-passkeys
