> ## 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.

# API testing passkeys

> Write automated API tests that enroll a passkey and complete a passkey challenge entirely in code.

This guide shows how to write automated **API tests** that enroll a passkey and complete a passkey challenge against Authsignal. The test process generates a passkey key pair, assembles the WebAuthn structures the server expects, and signs challenges itself. This is ideal when running a browser-based virtual authenticator is slow or impractical.

By the end you'll have two helpers `buildPasskeyRegistration` and `buildPasskeyAuthentication` and a complete example function covering the full **enroll → challenge → verify** loop.

The examples are TypeScript + raw HTTP that map to the [Authsignal API reference](/api-reference/overview). See the [Porting to other languages](#porting-to-other-languages) section at the end if you're not on Node.

## Prerequisites

* An Authsignal tenant with passkeys enabled
* Your **API secret key** (Portal → Settings → API Keys)
* A test runtime with Node.js 18+ (examples use `node:crypto` and `fetch`)
* The origin your app uses for passkeys (e.g. `https://your-test-app.example.com`)

## How it works

* **Enrollment**: generate a key pair, package the public key as a WebAuthn *attestation*, and register it. Keep the private key in memory.
* **Authentication**: when the server issues a challenge, build a WebAuthn *assertion* and sign it with that same private key. The server verifies the signature against the public key it stored at enrollment.

## Authentication & tokens

Every call below is one of two kinds:

* **Server API** calls authenticate with your **API secret key** using HTTP Basic auth.
* **Client API** calls authenticate with the short-lived **`token`** returned by `track`, passed as `Authorization: Bearer TOKEN`.

So the pattern is always: `track` an action to mint a token, then use that token for the passkey calls. The token enrolling a passkey needs permission to add authenticators, so pass `scope: "add:authenticators"` when tracking the enrollment action.

```ts authsignal-client.ts theme={null}
const BASE_URL = "https://api.authsignal.com"; // or au./eu./ca. for your region
const API_SECRET_KEY = process.env.AUTHSIGNAL_SECRET_KEY!;

async function trackAction(userId: string, action: string, scope?: string): Promise<string> {
  const auth = Buffer.from(`${API_SECRET_KEY}:`).toString("base64");

  const res = await fetch(
    `${BASE_URL}/v1/users/${encodeURIComponent(userId)}/actions/${encodeURIComponent(action)}`,
    {
      method: "POST",
      headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" },
      body: JSON.stringify(scope ? { scope } : {}),
    },
  );

  if (!res.ok) throw new Error(`track failed: ${res.status} ${await res.text()}`);

  const data = (await res.json()) as { token: string };
  return data.token;
}

async function clientPost<T>(path: string, token: string, body: unknown): Promise<T> {
  const res = await fetch(`${BASE_URL}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });

  if (!res.ok) throw new Error(`${path} failed: ${res.status} ${await res.text()}`);

  return res.json() as Promise<T>;
}
```

Reference: [Track Action](/api-reference/server-api/track-action).

## Setup

The only third-party dependency is a CBOR encoder, used to build the attestation during enrollment. The examples use [`@levischuck/tiny-cbor`](https://www.npmjs.com/package/@levischuck/tiny-cbor), but any spec-compliant CBOR library works. Authentication needs no CBOR at all.

```ts passkey.ts theme={null}
import { createHash, createSign, generateKeyPairSync, randomBytes, KeyObject } from "node:crypto";
import { encodeCBOR } from "@levischuck/tiny-cbor";

// Must match an allowed origin configured for your tenant's passkeys.
const ORIGIN = "https://your-test-app.example.com";

// COSE_Key map labels (RFC 8152)
const COSE_KTY = 1,
  COSE_ALG = 3,
  COSE_CRV = -1,
  COSE_X = -2,
  COSE_Y = -3;
const EC2_KTY = 2,
  ES256_ALG = -7,
  P256_CRV = 1;

// authenticatorData flag bits
const UP = 0x01; // user present
const UV = 0x04; // user verified
const AT = 0x40; // attested credential data included
```

<Warning>
  `rpId` and `origin` must match your tenant's passkey setup, or verification fails. This is the
  most common cause of `isVerified: false`. The examples **read `rpId` from the options response**
  so it can't drift, and you set `ORIGIN` once to a domain you've allow-listed for passkeys.
</Warning>

## Enrolling a passkey

Enrollment is three steps: ask for registration options (which include the server's challenge), build a credential, then submit it.

`buildPasskeyRegistration` generates the key pair, encodes the public key as a `COSE_Key`, and assembles `authenticatorData` followed by a `fmt: "none"` attestation object. It **returns the private key** so you can sign assertions later.

```ts passkey.ts theme={null}
type Passkey = {
  credentialId: string;
  privateKey: KeyObject;
  registrationCredential: Record<string, unknown>;
};

function buildPasskeyRegistration(params: {
  challenge: string;
  rpId: string;
  origin: string;
  aaguid?: string;
}): Passkey {
  const { challenge, rpId, origin, aaguid = "00000000-0000-0000-0000-000000000000" } = params;

  // 1. Generate a P-256 (ES256) key pair. Keep the private key to sign assertions later.
  const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
  const jwk = publicKey.export({ format: "jwk" });
  const x = Buffer.from(jwk.x!, "base64url");
  const y = Buffer.from(jwk.y!, "base64url");

  // 2. Encode the public key as a COSE_Key (CBOR map).
  const cosePublicKey = encodeCBOR(
    new Map<number, number | Uint8Array>([
      [COSE_KTY, EC2_KTY],
      [COSE_ALG, ES256_ALG],
      [COSE_CRV, P256_CRV],
      [COSE_X, x],
      [COSE_Y, y],
    ]),
  );

  // 3. Assemble authenticatorData.
  const credentialId = randomBytes(32);
  const rpIdHash = createHash("sha256").update(rpId).digest();
  const flags = Buffer.from([UP | UV | AT]); // 0x45
  const signCount = Buffer.alloc(4); // 0
  const aaguidBytes = Buffer.from(aaguid.replace(/-/g, ""), "hex");
  const credIdLen = Buffer.alloc(2);
  credIdLen.writeUInt16BE(credentialId.length, 0);

  const authData = Buffer.concat([
    rpIdHash,
    flags,
    signCount,
    aaguidBytes,
    credIdLen,
    credentialId,
    Buffer.from(cosePublicKey),
  ]);

  // 4. clientDataJSON: echo the server's challenge verbatim.
  const clientDataJSON = Buffer.from(
    JSON.stringify({ type: "webauthn.create", challenge, origin, crossOrigin: false }),
  );

  // 5. attestationObject with "none" attestation.
  const attestationObject = encodeCBOR(
    new Map<string, string | Uint8Array | Map<never, never>>([
      ["fmt", "none"],
      ["attStmt", new Map()],
      ["authData", authData],
    ]),
  );

  const credentialIdB64 = credentialId.toString("base64url");

  return {
    credentialId: credentialIdB64,
    privateKey,
    registrationCredential: {
      id: credentialIdB64,
      rawId: credentialIdB64,
      type: "public-key",
      authenticatorAttachment: "platform",
      clientExtensionResults: {},
      response: {
        clientDataJSON: clientDataJSON.toString("base64url"),
        attestationObject: Buffer.from(attestationObject).toString("base64url"),
        transports: ["internal"],
      },
    },
  };
}
```

### Submitting the credential

```ts theme={null}
const enrollToken = await trackAction(userId, "passkey-enroll", "add:authenticators");

const regOptions = await clientPost<{
  challengeId: string;
  options: { challenge: string; rp?: { id?: string } };
}>("/v1/client/user-authenticators/passkey/registration-options", enrollToken, {
  username: userId,
});

const rpId = regOptions.options.rp?.id ?? new URL(ORIGIN).hostname;

const passkey = buildPasskeyRegistration({
  challenge: regOptions.options.challenge,
  rpId,
  origin: ORIGIN,
});

const addResult = await clientPost<{ isVerified: boolean; userAuthenticatorId?: string }>(
  "/v1/client/user-authenticators/passkey",
  enrollToken,
  { challengeId: regOptions.challengeId, registrationCredential: passkey.registrationCredential },
);

if (!addResult.isVerified) throw new Error("Passkey enrollment failed");
```

References: [Generate Passkey Registration Options](/api-reference/client-api/generate-passkey-registration-options), [Verify Passkey Registration](/api-reference/client-api/verify-passkey-registration).

## Authenticating with a passkey

Now complete a challenge with the passkey you just enrolled. Ask for authentication options, build a signed assertion, and submit it for verification.

The key idea: **the server verifies an ES256 signature over `authenticatorData ‖ SHA-256(clientDataJSON)` using the public key captured at registration.**

```ts passkey.ts theme={null}
function buildPasskeyAuthentication(params: {
  challenge: string;
  rpId: string;
  origin: string;
  credentialId: string;
  privateKey: KeyObject;
}): Record<string, unknown> {
  const { challenge, rpId, origin, credentialId, privateKey } = params;

  // authenticatorData for an assertion: no attested credential data, so it ends after signCount.
  const rpIdHash = createHash("sha256").update(rpId).digest();
  const flags = Buffer.from([UP | UV]); // 0x05
  const signCount = Buffer.alloc(4); // 0
  const authenticatorData = Buffer.concat([rpIdHash, flags, signCount]);

  const clientDataJSON = Buffer.from(
    JSON.stringify({ type: "webauthn.get", challenge, origin, crossOrigin: false }),
  );

  // Sign authenticatorData || SHA-256(clientDataJSON) with the registered private key (ES256).
  const clientDataHash = createHash("sha256").update(clientDataJSON).digest();
  const signature = createSign("sha256")
    .update(Buffer.concat([authenticatorData, clientDataHash]))
    .sign(privateKey); // ECDSA / DER-encoded, as WebAuthn expects

  return {
    id: credentialId,
    rawId: credentialId,
    type: "public-key",
    authenticatorAttachment: "platform",
    clientExtensionResults: {},
    response: {
      clientDataJSON: clientDataJSON.toString("base64url"),
      authenticatorData: authenticatorData.toString("base64url"),
      signature: signature.toString("base64url"),
    },
  };
}
```

Submitting the assertion:

```ts theme={null}
const verifyToken = await trackAction(userId, "passkey-signin");

const authOptions = await clientPost<{
  challengeId: string;
  options: { challenge: string; rpId?: string };
}>("/v1/client/user-authenticators/passkey/authentication-options", verifyToken, {
  username: userId,
});

const authenticationCredential = buildPasskeyAuthentication({
  challenge: authOptions.options.challenge,
  rpId: authOptions.options.rpId ?? rpId,
  origin: ORIGIN,
  credentialId: passkey.credentialId,
  privateKey: passkey.privateKey,
});

const verifyResult = await clientPost<{ isVerified: boolean; accessToken?: string }>(
  "/v1/client/verify/passkey",
  verifyToken,
  { challengeId: authOptions.challengeId, authenticationCredential },
);

if (!verifyResult.isVerified) throw new Error("Passkey verification failed");
// verifyResult.accessToken can be validated server-side to complete the action.
```

References: [Generate Passkey Authentication Options](/api-reference/client-api/generate-passkey-authentication-options), [Verify Passkey Authentication](/api-reference/client-api/verify-passkey-authentication).

## Complete helper example

Everything together. This framework-agnostic sample gives you one function to call from a `test(...)` in `Vitest`, Jest, or any runner.

```ts passkey-api.test.ts theme={null}
import { createHash, createSign, generateKeyPairSync, randomBytes, KeyObject } from "node:crypto";
import { encodeCBOR } from "@levischuck/tiny-cbor";

const BASE_URL = "https://api.authsignal.com";
const API_SECRET_KEY = process.env.AUTHSIGNAL_SECRET_KEY!;
const ORIGIN = "https://your-test-app.example.com";

const COSE_KTY = 1,
  COSE_ALG = 3,
  COSE_CRV = -1,
  COSE_X = -2,
  COSE_Y = -3;
const EC2_KTY = 2,
  ES256_ALG = -7,
  P256_CRV = 1;
const UP = 0x01,
  UV = 0x04,
  AT = 0x40;

type Passkey = {
  credentialId: string;
  privateKey: KeyObject;
  registrationCredential: Record<string, unknown>;
};

async function trackAction(userId: string, action: string, scope?: string): Promise<string> {
  const auth = Buffer.from(`${API_SECRET_KEY}:`).toString("base64");
  const res = await fetch(
    `${BASE_URL}/v1/users/${encodeURIComponent(userId)}/actions/${encodeURIComponent(action)}`,
    {
      method: "POST",
      headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" },
      body: JSON.stringify(scope ? { scope } : {}),
    },
  );
  if (!res.ok) throw new Error(`track failed: ${res.status} ${await res.text()}`);
  return ((await res.json()) as { token: string }).token;
}

async function clientPost<T>(path: string, token: string, body: unknown): Promise<T> {
  const res = await fetch(`${BASE_URL}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`${path} failed: ${res.status} ${await res.text()}`);
  return res.json() as Promise<T>;
}

function buildPasskeyRegistration(params: {
  challenge: string;
  rpId: string;
  origin: string;
  aaguid?: string;
}): Passkey {
  const { challenge, rpId, origin, aaguid = "00000000-0000-0000-0000-000000000000" } = params;

  const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
  const jwk = publicKey.export({ format: "jwk" });
  const x = Buffer.from(jwk.x!, "base64url");
  const y = Buffer.from(jwk.y!, "base64url");

  const cosePublicKey = encodeCBOR(
    new Map<number, number | Uint8Array>([
      [COSE_KTY, EC2_KTY],
      [COSE_ALG, ES256_ALG],
      [COSE_CRV, P256_CRV],
      [COSE_X, x],
      [COSE_Y, y],
    ]),
  );

  const credentialId = randomBytes(32);
  const rpIdHash = createHash("sha256").update(rpId).digest();
  const flags = Buffer.from([UP | UV | AT]);
  const signCount = Buffer.alloc(4);
  const aaguidBytes = Buffer.from(aaguid.replace(/-/g, ""), "hex");
  const credIdLen = Buffer.alloc(2);
  credIdLen.writeUInt16BE(credentialId.length, 0);

  const authData = Buffer.concat([
    rpIdHash,
    flags,
    signCount,
    aaguidBytes,
    credIdLen,
    credentialId,
    Buffer.from(cosePublicKey),
  ]);

  const clientDataJSON = Buffer.from(
    JSON.stringify({ type: "webauthn.create", challenge, origin, crossOrigin: false }),
  );

  const attestationObject = encodeCBOR(
    new Map<string, string | Uint8Array | Map<never, never>>([
      ["fmt", "none"],
      ["attStmt", new Map()],
      ["authData", authData],
    ]),
  );

  const credentialIdB64 = credentialId.toString("base64url");

  return {
    credentialId: credentialIdB64,
    privateKey,
    registrationCredential: {
      id: credentialIdB64,
      rawId: credentialIdB64,
      type: "public-key",
      authenticatorAttachment: "platform",
      clientExtensionResults: {},
      response: {
        clientDataJSON: clientDataJSON.toString("base64url"),
        attestationObject: Buffer.from(attestationObject).toString("base64url"),
        transports: ["internal"],
      },
    },
  };
}

function buildPasskeyAuthentication(params: {
  challenge: string;
  rpId: string;
  origin: string;
  credentialId: string;
  privateKey: KeyObject;
}): Record<string, unknown> {
  const { challenge, rpId, origin, credentialId, privateKey } = params;

  const rpIdHash = createHash("sha256").update(rpId).digest();
  const flags = Buffer.from([UP | UV]);
  const signCount = Buffer.alloc(4);
  const authenticatorData = Buffer.concat([rpIdHash, flags, signCount]);

  const clientDataJSON = Buffer.from(
    JSON.stringify({ type: "webauthn.get", challenge, origin, crossOrigin: false }),
  );

  const clientDataHash = createHash("sha256").update(clientDataJSON).digest();
  const signature = createSign("sha256")
    .update(Buffer.concat([authenticatorData, clientDataHash]))
    .sign(privateKey);

  return {
    id: credentialId,
    rawId: credentialId,
    type: "public-key",
    authenticatorAttachment: "platform",
    clientExtensionResults: {},
    response: {
      clientDataJSON: clientDataJSON.toString("base64url"),
      authenticatorData: authenticatorData.toString("base64url"),
      signature: signature.toString("base64url"),
    },
  };
}

async function testPasskeyEnrollAndVerify() {
  const userId = `test-user-${randomBytes(8).toString("hex")}`;

  // --- Enroll ---
  const enrollToken = await trackAction(userId, "passkey-enroll", "add:authenticators");

  const regOptions = await clientPost<{
    challengeId: string;
    options: { challenge: string; rp?: { id?: string } };
  }>("/v1/client/user-authenticators/passkey/registration-options", enrollToken, {
    username: userId,
  });

  const rpId = regOptions.options.rp?.id ?? new URL(ORIGIN).hostname;

  const passkey = buildPasskeyRegistration({
    challenge: regOptions.options.challenge,
    rpId,
    origin: ORIGIN,
  });

  const addResult = await clientPost<{ isVerified: boolean; userAuthenticatorId?: string }>(
    "/v1/client/user-authenticators/passkey",
    enrollToken,
    { challengeId: regOptions.challengeId, registrationCredential: passkey.registrationCredential },
  );

  if (!addResult.isVerified) throw new Error("Passkey enrollment failed");

  // --- Authenticate ---
  const verifyToken = await trackAction(userId, "passkey-signin");

  const authOptions = await clientPost<{
    challengeId: string;
    options: { challenge: string; rpId?: string };
  }>("/v1/client/user-authenticators/passkey/authentication-options", verifyToken, {
    username: userId,
  });

  const authenticationCredential = buildPasskeyAuthentication({
    challenge: authOptions.options.challenge,
    rpId: authOptions.options.rpId ?? rpId,
    origin: ORIGIN,
    credentialId: passkey.credentialId,
    privateKey: passkey.privateKey,
  });

  const verifyResult = await clientPost<{ isVerified: boolean; accessToken?: string }>(
    "/v1/client/verify/passkey",
    verifyToken,
    { challengeId: authOptions.challengeId, authenticationCredential },
  );

  if (!verifyResult.isVerified) throw new Error("Passkey verification failed");

  return verifyResult.accessToken;
}
```

## Porting to other languages

The Authsignal calls are plain HTTP, so only the credential building is language-specific. Any language can do it with four primitives:

1. **Generate an EC P-256 (secp256r1) key pair** and keep the private key in memory.
2. **Encode the public key as a `COSE_Key`**: a CBOR map with `kty=2`, `alg=-7`, `crv=1`, and the `x` / `y` coordinates.
3. **Assemble `authenticatorData` and the `attestationObject`** (`fmt: "none"`) for registration; base64url-encode the parts.
4. **For authentication, ES256-sign** `authenticatorData ‖ SHA-256(clientDataJSON)`, DER-encode the signature, and base64url everything.

For the full structure definitions, see the [WebAuthn specification](https://www.w3.org/TR/webauthn-2/) and [MDN's Web Authentication API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API).
