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

# FIDO2 / WebAuthn

> Enroll customer passkeys, verify live consent, and export opt-in portable WebAuthn assertion evidence.

## Overview

The Grantex-hosted service enabled portable WebAuthn evidence on September 27,
2026\. Production browser E2E verified enrollment, live approval, assertion
evidence, refresh, delegation, account response policy, and public VC
revocation status. Self-hosted deployments still default to off and must
follow the rollout below.

Grantex supports FIDO2/WebAuthn passkeys during consent. A valid assertion proves use of a credential bound to the principal and to Grantex's challenge. When hosted enrollment is enabled, the server also requires WebAuthn user verification during registration and assertion; this may be a device PIN, biometric, or other authenticator-supported method. The server does not learn which method the customer used.

Live-mode consent requires an existing passkey for the principal. Sandbox-mode accounts can also require one with `fidoRequired: true`. A consent link alone cannot register a passkey because it does not authenticate the principal.

The Grantex-hosted service runs with `PASSKEY_ENROLLMENT_ENABLED=true`, `FIDO_RP_ID=grantex.dev`, and `FIDO_ORIGIN=https://grantex.dev`; its production browser flow has passed enrollment and live-consent E2E. Self-hosted deployments must enable the flag and configure their own public HTTPS origin and RP ID. The feature is off by default there; do not enable live mode for customers until they can complete an enrollment test on that origin.

The hosted enrollment client is published in `@grantex/sdk@0.8.0`, `grantex==0.7.0`, and `github.com/mishrasanjeev/grantex-go@v0.4.1`. These are independently versioned packages; installation does not turn on the server feature. Check [Release Status](/release-status) for current registry evidence and rollout limitations.

Portable evidence is a separate, default-off source rollout: `PORTABLE_WEBAUTHN_EVIDENCE_ENABLED=true`. It requires `IRREGULARITY_CASCADE_REVOCATION_ENABLED=true` so automatic grant revocation also updates VCs and public status lists, and `PORTABLE_WEBAUTHN_EVIDENCE_STATUS_CHECK_ENABLED=true` so issuer verification checks stored grant and ancestor status. Keep both safety flags enabled if issuance is rolled back. The hosted service has all three enabled. TypeScript, Python, and Go SDKs expose the typed grant evidence reference and VC attestation in their newly verified releases; custom assertion UIs still use the REST endpoints directly.

## Why FIDO for Agents?

Standard consent flows rely on a button click in a browser. A verified WebAuthn assertion establishes that:

1. The specific device registered by the user is present
2. User verification was performed when the hosted-enrollment policy requires it; the legacy developer registration path only requests it as preferred
3. The assertion is bound to the specific challenge issued by Grantex

For high-value operations, use hosted enrollment with required user verification and your own customer identity-binding controls. Portable evidence is available only for new ceremonies with an opt-in VC; the consent gate alone is not payment-network certification.

## Developer Setup

### Sandbox And Live Parity

| Account mode | `fidoRequired` | Authorization behavior |
| - | - | - |
| Sandbox | `false` | Auto-approval for integration testing; no proof of human consent |
| Sandbox | `true` | Pending consent, enrollment and verified passkey assertion before approval or denial |
| Live | Either value | Pending consent, enrollment and verified passkey assertion before approval or denial |

For a realistic sandbox rehearsal, set `fidoRequired: true` before requesting
authorization. Both `/v1/authorize` and OAuth PAR then redirect to consent
instead of returning an automatic code. Developer-key approve/deny shortcuts
cannot bypass that requirement. Live mode never inherits sandbox auto-approval.
OAuth requests without a principal hint let the customer enter the principal
identifier, then verify a passkey registered for that principal; typing an
identifier is not itself authentication.

Enable FIDO for your account using the SDK or REST API:

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { Grantex } from '@grantex/sdk';

  const grantex = new Grantex({ apiKey: process.env.GRANTEX_API_KEY });

  await grantex.updateSettings({
    fidoRequired: true,
    fidoRpName: 'My Application',  // displayed in browser passkey prompts
  });
  ```

  ```python Python theme={null}
  from grantex import Grantex, UpdateDeveloperSettingsParams

  client = Grantex(api_key="gx_...")

  client.update_settings(UpdateDeveloperSettingsParams(
      fido_required=True,
      fido_rp_name="My Application",
  ))
  ```

  ```bash cURL theme={null}
  curl -X PATCH https://api.grantex.dev/v1/me \
    -H "Authorization: Bearer $GRANTEX_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"fidoRequired": true, "fidoRpName": "My Application"}'
  ```
</CodeGroup>

<ParamField body="fidoRequired" type="boolean" default="false">
  When `true`, sandbox-mode authorization requests also require a WebAuthn assertion. Live mode already requires one regardless of this setting.
</ParamField>

<ParamField body="fidoRpName" type="string">
  The Relying Party name displayed in the browser's passkey prompt. Typically your application name.
</ParamField>

## Hosted Enrollment For End Customers

Your application must first authenticate the end customer and bind that session to the exact `principalId` it uses in Grantex. From your **server**, issue a short-lived, one-use enrollment link and deliver it to that customer through the authenticated session. Never expose your developer API key in browser code. The enrollment link itself is a bearer secret: do not log it, put it in analytics, or send it to another customer.

```typescript theme={null}
// Run on your server with @grantex/sdk@0.8.0, after authenticating the customer.
const { enrollmentUrl, expiresAt } = await grantex.webauthn.createEnrollmentSession({
  principalId: authenticatedCustomer.grantexPrincipalId,
  // Optional: link back to a pending consent request for the same principal.
  authRequestId,
});
// Show enrollmentUrl only to authenticatedCustomer. It expires after 10 minutes.
```

Python `grantex==0.7.0` uses `client.webauthn.create_enrollment_session(principal_id=principal_id, auth_request_id=auth_request_id)`; Go `v0.4.1` uses `client.WebAuthn.CreateEnrollmentSession(ctx, grantex.WebAuthnEnrollmentSessionParams{PrincipalID: principalID, AuthRequestID: authRequestID})`. Both calls belong on your authenticated application server, not in browser code.

The customer opens the link at `/passkey-enroll`, uses their device's passkey prompt, and returns to consent when the link was bound to an authorization request. The browser registration challenge expires after five minutes and must be restarted if it expires. The registration requires device user verification. An enrollment ticket can register only one credential and cannot be reused. To add another device, create another link.

The developer portal's **Passkeys** page can also issue a link for a principal. Operators must establish the customer's identity before sharing it. There is deliberately no unauthenticated "register" button on the consent page: anyone holding a consent URL could otherwise enroll their own passkey as the principal.

If enrollment is disabled (404) or the customer cannot use a passkey-capable browser, live-mode approval remains blocked. Sandbox mode can be used for integration testing, but it is not an alternative live-mode approval path.

For production rollout, deploy the auth service and the `/passkey-enroll` public-hosting rewrite first. Confirm `FIDO_ORIGIN` is the exact public HTTPS origin and `FIDO_RP_ID` is its valid relying-party domain, then enable `PASSKEY_ENROLLMENT_ENABLED=true`. Test registration and live approval on that origin before sending links to customers. The repository includes `apps/auth-service/tests/production/passkey-policy.test.ts` and the manual `Production Passkey and Irregularity E2E` GitHub Actions workflow. That test creates an isolated production account, passkey, grant and audit records; it must not use a customer account.

## Portable Evidence Rollout

To enable portable evidence, first deploy migration `119_webauthn_evidence.sql` and confirm the enabled Docker/Chromium E2E suite and all CI checks are green. Test the deployed RP ID/origin with an isolated account. Enable `IRREGULARITY_CASCADE_REVOCATION_ENABLED=true` and `PORTABLE_WEBAUTHN_EVIDENCE_STATUS_CHECK_ENABLED=true`, then reconcile historical revoked grants and credentials using `node dist/scripts/reconcile-revoked-vcs.js` (dry run) followed by `--apply` on an authorized maintenance worker. Only then enable `PORTABLE_WEBAUTHN_EVIDENCE_ENABLED=true` on the auth service; startup rejects the portable flag without both safety flags. Run the `Production Passkey and Irregularity E2E` workflow after activation. New assertions will carry evidence; existing grants do not gain it retroactively. Approved live requests that predate activation have no captured assertion and fail token exchange with `PASSKEY_EVIDENCE_REQUIRED`; restart consent for those customers. To stop new issuance, disable only `PORTABLE_WEBAUTHN_EVIDENCE_ENABLED`; leave the two safety flags on for already-issued credentials.

Before trusting old VCs' public status lists, reconcile active child grants and credentials left behind by historical grant-only irregularity revocations. From an auth-service build with database access, run `node dist/scripts/reconcile-revoked-vcs.js` for counts, then `node dist/scripts/reconcile-revoked-vcs.js --apply` to cascade the old revocations and update VC rows and status-list bits. This command is idempotent and prints counts, not credential data. Keep cascade revocation and evidence status checks enabled even if portable issuance is rolled back: turning off only `PORTABLE_WEBAUTHN_EVIDENCE_ENABLED` stops new capture and restores legacy exchange for old no-evidence requests, while previously captured evidence remains verifiable and propagates on refresh/delegation. Keep the opt-in VCs' stable public keys only as long as necessary.

For local verification, start a disposable Postgres instance, set `AUDIT_INTEGRATION_DATABASE_URL`, install Chromium with `npx playwright install chromium`, and run `npm run test:e2e` from `apps/auth-service`. The browser suite uses a virtual authenticator and a real local Postgres database; it cannot by itself prove that a hosted production origin, routing rewrite, or customer device is configured correctly.

## Developer-Driven Registration Ceremony

The hosted link above is the recommended customer flow. The older developer-authenticated `register/options` and `register/verify` endpoints can drive registration from your own application, but they request user verification as `preferred`, not `required`. Do not treat a credential created through that path as verified human presence at enrollment. Register a new passkey for each device.

```
  Browser                          Your Server                     Grantex
    │                                  │                              │
    │                                  │─── registerOptions() ───────►│
    │                                  │◄── challenge + options ──────│
    │◄── publicKey options ────────────│                              │
    │                                  │                              │
    │── navigator.credentials.create() │                              │
    │── (user touches fingerprint) ────│                              │
    │                                  │                              │
    │── attestation response ─────────►│                              │
    │                                  │─── registerVerify() ────────►│
    │                                  │◄── credential stored ────────│
    │◄── success ──────────────────────│                              │
```

### Step 1: Get Registration Options

<CodeGroup>
  ```typescript TypeScript theme={null}
  const options = await grantex.webauthn.registerOptions({
    principalId: 'user_abc123',
  });

  // options contains:
  // - challengeId: string (server-side reference)
  // - publicKey: { rp, user, challenge, pubKeyCredParams, ... }
  // - publicKey.pubKeyCredParams: [{ type: 'public-key', alg: -7 }, ...]
  // - publicKey.authenticatorSelection: { userVerification: 'preferred' } on legacy API
  // - publicKey.timeout: browser timeout
  // - publicKey.challenge: base64url-encoded
  ```

  ```python Python theme={null}
  options = client.webauthn.register_options(principal_id="user_abc123")
  # Returns challengeId and publicKey registration options.
  ```
</CodeGroup>

### Step 2: Create Credential in Browser

Pass the options to the browser's WebAuthn API:

```javascript theme={null}
// In the browser
const credential = await navigator.credentials.create({
  publicKey: {
    ...options.publicKey,
    challenge: base64urlToBuffer(options.publicKey.challenge),
    user: {
      ...options.publicKey.user,
      id: base64urlToBuffer(options.publicKey.user.id),
    },
    excludeCredentials: (options.publicKey.excludeCredentials || []).map((item) => ({
      ...item, id: base64urlToBuffer(item.id),
    })),
  },
});
```

### Step 3: Verify and Store

Send the credential response back to your server and forward it to Grantex:

<CodeGroup>
  ```typescript TypeScript theme={null}
  await grantex.webauthn.registerVerify({
    challengeId: options.challengeId,
    response: {
      id: credential.id,
      rawId: bufferToBase64url(credential.rawId),
      type: credential.type,
      response: {
        clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
        attestationObject: bufferToBase64url(credential.response.attestationObject),
      },
      clientExtensionResults: credential.getClientExtensionResults(),
    },
  });
  ```

  ```python Python theme={null}
  from grantex import WebAuthnRegistrationVerifyParams

  client.webauthn.register_verify(WebAuthnRegistrationVerifyParams(
      challenge_id=options.challenge_id,
      response={
          "id": credential_id,
          "rawId": raw_id_b64,
          "type": "public-key",
          "response": {
              "clientDataJSON": client_data_b64,
              "attestationObject": attestation_b64,
          },
      },
  ))
  ```
</CodeGroup>

## Assertion Ceremony (Consent Flow)

When FIDO is enabled, the consent flow includes a WebAuthn assertion challenge. This happens automatically in the hosted consent UI, but you can also drive it programmatically.

```
  Browser                          Grantex Consent UI              Grantex
    │                                  │                              │
    │── user opens consent URL ───────►│                              │
    │                                  │── POST assert/options ──────►│
    │                                  │◄── challenge ────────────────│
    │◄── publicKey get options ────────│                              │
    │                                  │                              │
    │── navigator.credentials.get() ──►│                              │
    │── (user touches fingerprint) ────│                              │
    │                                  │                              │
    │── assertion response ───────────►│                              │
    │                                  │── POST assert/verify ───────►│
    │                                  │── POST consent/approve ─────►│
    │                                  │◄── authorization code ───────│
    │◄── redirect to callback ─────────│                              │
```

### Programmatic Assertion

If you build a custom consent UI, call `POST /v1/webauthn/assert/options` with the pending `authRequestId` and bound `principalId`. The response contains `{ challengeId, publicKey }`; decode `publicKey.challenge` and each `publicKey.allowCredentials[].id` from base64url before calling `navigator.credentials.get({ publicKey })`. Serialize the returned assertion's `id`, `rawId`, `type`, `response.clientDataJSON`, `response.authenticatorData`, `response.signature`, optional `response.userHandle`, and `clientExtensionResults` as base64url where applicable. Send `{ challengeId, response }` to `POST /v1/webauthn/assert/verify`, then separately call `POST /v1/consent/{authRequestId}/approve` or `/deny`. Verification only marks the pending request as FIDO-verified; it does **not** approve the grant by itself. The hosted consent page implements this sequence. The published TypeScript, Python, and Go WebAuthn clients do not currently expose `assertOptions`/`assertVerify` helper methods; use these REST endpoints for a custom UI.

## Managing Credentials

Users can have multiple passkeys registered (e.g., laptop fingerprint + phone + YubiKey). Use the credentials endpoints to list and delete them.

Enroll each additional device with a new one-use link. An authenticator that
already has a credential for this principal is excluded from duplicate
registration. Deleting a credential stops future assertions with that key;
it does not retroactively invalidate existing grants or historical assertion
evidence. Revoke the affected grants separately when removing access.
Agents with issued VCs retain their grant and credential history: attempting
to hard-delete one returns `409 AGENT_HAS_CREDENTIAL_HISTORY`. Suspend the
agent and revoke its grants instead; its existing public credential status
must remain available.

<CodeGroup>
  ```typescript TypeScript theme={null}
  // List all credentials for a user
  const { credentials } = await grantex.webauthn.listCredentials('user_abc123');
  for (const cred of credentials) {
    console.log(cred.id, cred.createdAt, cred.lastUsedAt);
  }

  // Delete a credential
  await grantex.webauthn.deleteCredential('cred_01HXYZ...');
  ```

  ```python Python theme={null}
  # List all credentials for a user
  credentials = client.webauthn.list_credentials("user_abc123").credentials
  for cred in credentials:
      print(cred.id, cred.created_at, cred.last_used_at)

  # Delete a credential
  client.webauthn.delete_credential("cred_01HXYZ...")
  ```
</CodeGroup>

## Portable Assertion Evidence

With portable evidence enabled, successful assertion verification stores the raw assertion, challenge, credential public key, prior counter, RP ID, origin, and assertion time on the pending authorization request. Approval or denial remains a separate operation. Token exchange rechecks the assertion, binds it to the grant, and places a compact `webauthnEvidence` digest reference in the signed grant token and grant API response. Refresh preserves this reference. An approved live request from before evidence capture cannot be exchanged without new passkey consent while the flag is enabled.

Request `credentialFormat: "vc-jwt"` or `"both"` at token exchange to obtain a VC with the complete `GrantexWebAuthnAssertion` in `vc.evidence`. The public credential key in this opt-in export is stable and can correlate presentations; minimize sharing and retention. Independent verifiers must check the VC's issuer signature, expiry and revocation, pin a trusted RP ID and origin, reverify the assertion signature/challenge/counter, and compare its digest with the grant reference. The authenticator did not sign the scopes or independently prove the customer's legal identity: the issuer signature and your enrollment identity checks supply those bindings. See [Verifiable Credentials](/features/verifiable-credentials). No payment-network certification is claimed.

## API Reference

| Method | Endpoint | Description |
| - | - | - |
| `POST` | `/v1/webauthn/register/options` | Generate passkey registration options |
| `POST` | `/v1/webauthn/register/verify` | Verify registration and store credential |
| `POST` | `/v1/webauthn/enrollment-sessions` | Issue an authenticated one-use enrollment link |
| `GET` | `/passkey-enroll` | Hosted browser enrollment page |
| `POST` | `/v1/webauthn/enroll/options` | Exchange a valid enrollment ticket for registration options |
| `POST` | `/v1/webauthn/enroll/verify` | Verify the passkey and consume the ticket |
| `GET` | `/v1/webauthn/credentials` | List WebAuthn credentials for a principal |
| `DELETE` | `/v1/webauthn/credentials/:id` | Delete a credential |
| `POST` | `/v1/webauthn/assert/options` | Generate assertion options for consent |
| `POST` | `/v1/webauthn/assert/verify` | Verify assertion during consent |
| `PATCH` | `/v1/me` | Update developer settings (FIDO config) |

## Security Considerations

* **User verification**: Hosted enrollment requires `userVerification: "required"` and verifies it on the server. With enrollment enabled, consent assertions also require it. The legacy developer-authenticated registration API requests `"preferred"`; do not treat those older ceremonies as proof of user verification.
* **Challenge expiry**: Registration and assertion challenges expire after 5 minutes. Replaying an expired challenge returns a 400 error.
* **Credential binding**: Each credential is bound to a specific principal and developer. A credential registered for one developer's consent flow cannot be used for another.
* **Attestation**: Grantex accepts `"none"`, `"indirect"`, and `"direct"` attestation conveyance. Attestation statements are stored but not currently used for trust decisions.
* **Missing credential**: Consent cannot be approved until the application issues an authenticated enrollment link and the customer registers a passkey. There is no weaker live-mode fallback.

## Release Validation Boundary

The repeatable browser checks are in
`tests/e2e/passkey-policy-browser.e2e.test.ts` (real Postgres and Chromium),
`tests/production/passkey-parity.test.ts` (sandbox/live parity, multiple devices,
denial, approval, principal selection, OAuth callbacks and revocation), and
`tests/production/passkey-policy.test.ts` (portable evidence, refresh,
delegation, public VC status and both irregularity response modes), and
`tests/production/passkey-portal.test.ts` (dashboard login, enrollment-link
issuance, credential listing and confirmed removal).
The manual **Production Passkey and Irregularity E2E** workflow runs all three
production files against isolated accounts on the hosted HTTPS origin.

Credential removal sends an authenticated, bodyless `DELETE` request. Do not
attach `Content-Type: application/json` without a JSON body; the API rejects
that malformed request with HTTP 400. The dashboard handles the successful
HTTP 204 response without attempting to parse an empty JSON response.

Successful automation is sign-off for these tested service/browser contracts,
not a guarantee that every customer device, operating system or browser works.
Chromium uses virtual authenticators. Before rollout to your customers, test
your supported physical devices, user-verification prompts, cancellation,
recovery and accessibility. Your application still owns customer
authentication, principal binding and secure delivery of enrollment links.
No unauthenticated enrollment, weaker live consent fallback, or
payment-network certification is supplied by these tests.

## Next Steps

* [Verifiable Credentials](/features/verifiable-credentials) -- assertion export and independent verification
* [DID Infrastructure](/features/did-infrastructure) -- how verifiers resolve Grantex's signing keys
* [End-User Permissions](/guides/end-user-permissions) -- user-facing permission dashboard

## Ownership

Grantex is owned by Orchestrum Technologies LLP. Inventor and owner: Sanjeev Kumar. Ownership contact: [sanjeev@orchestrum.in](mailto:sanjeev@orchestrum.in) or [mishra.sanjeev@gmail.com](mailto:mishra.sanjeev@gmail.com).


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