Prompt Your LLM: V1.2 Passkeys

Copy the prompt for your platform into ChatGPT, Claude, Cursor, or another AI coding assistant.


Greenfield — full V1.2 + passkeys

Build a complete Akedly Shield V1.2 authentication integration for my web application. Generate a Node.js/Express backend and production-ready browser code. The finished flow must support PoW, optional Turnstile, OTP send/verify, passkey enrollment, passkey authentication, OTP fallback, and backend resultToken verification.

## Security boundary

- Keep APIKey and pipelineID only in backend environment variables.
- Browser code calls only my backend. It must never call api.akedly.io or contain Akedly credentials.
- The only browser call to an Akedly host is the SDK-managed ceremony at https://auth.akedly.io/pk.
- Configure Express `trust proxy` before using `req.ip`. Forward `x-end-user-ip: req.ip` on gated Akedly requests only when trusted-proxy configuration is enabled.

## Backend routes

Generate these application routes:

1. GET /auth/akedly/challenge
   Proxy GET https://api.akedly.io/api/v1.2/transactions/challenge with APIKey and pipelineID.
2. POST /auth/akedly/send
   Accept phoneNumber, powSolution, and optional turnstileToken. Proxy POST /api/v1.2/transactions/send with:
     APIKey, pipelineID, verificationAddress: { phoneNumber }, powSolution, turnstileToken
3. POST /auth/akedly/verify
   Accept transactionReqID and otp. Proxy POST /api/v1.2/transactions/verify with:
     transactionReqID, otp, returnTarget: { origin: "https://your-site.example" }
   The origin is backend configuration, not client input. It is a bare origin with no trailing slash.
4. POST /auth/akedly/passkey/auth-options
   Accept phoneNumber, powSolution, and optional turnstileToken. Proxy POST /api/v1.2/transactions/passkey/auth-options with:
     APIKey, pipelineID, verificationAddress: { phoneNumber }, powSolution, turnstileToken,
     returnTarget: { origin: "https://your-site.example" }
   Store the returned requestID and ceremonyToken against the sign-in attempt.
   Generate a cryptographically random attemptHandle. Persist its hash with requestID, ceremonyToken, configured pipelineID, stored identity, expiry, and unused state. Return data.attemptHandle alongside the unchanged upstream data, or bind the handle through a Secure, HttpOnly, SameSite cookie.
5. POST /auth/akedly/passkey/complete
   Accept resultToken and an opaque sign-in-attempt handle. Load the expected requestID, configured pipelineID, and session identity from server-side attempt state; never accept those bindings from the client. Verify the proof below, atomically consume the proof and attempt, then create the session for the stored identity.
6. POST /auth/akedly/passkey/reconcile
   Load ceremonyToken and expected requestID from server-side attempt state, then call GET /api/v1.2/transactions/passkey/result?token=.... On 200 verified, require transactionID to match the stored requestID and complete the stored identity's session. On pending, retry with bounded backoff and do not start OTP. On failed or 200 expired, atomically permit one fresh OTP fallback. Treat 410 PASSKEY_TOKEN_EXPIRED as indeterminate: never authorize from the unsigned callback or fall back automatically. If a correlated success callback exists, suppress OTP and show a recovery message. Otherwise offer an explicit user-initiated "Use OTP instead" action that atomically closes the attempt and acknowledges that the previous outcome could not be recovered.

Preserve Akedly response status codes and JSON envelopes. Validate all client input and do not accept a client-supplied relay target.

## Shield flow

The challenge response is:
  { status, data: { challenge, difficulty, challengeToken, challengeRequired, turnstile: { required, siteKey } } }

Install @akedly/shield. When challengeRequired is true, use `const { nonce } = await solvePow(challenge, difficulty)`. When challengeRequired is false, omit powSolution entirely. If Turnstile is required, use getTurnstileToken. Send:
  { phoneNumber, powSolution: { challengeToken, nonce }, turnstileToken }

For a returning user:

1. Require a user gesture. If isPasskeySupported() is false, start OTP.
2. Open a blank popup synchronously inside the click handler so popup blockers cannot race the async request.
3. Fetch and solve a fresh Shield challenge, then call my /auth/akedly/passkey/auth-options route.
4. On 404 with code "NO_PASSKEY", close the popup and start OTP with a new challenge, new PoW solution, and fresh Turnstile token when required. The speculative auth-options call consumes its PoW solution and any Turnstile token even though it creates no passkey request and consumes no passkey rate-limit slot.
5. On 403 with code "PASSKEY_DISABLED", start OTP. For passkey-specific 429 responses, honor retryAfter and offer a user-initiated OTP option rather than automatically triggering the paid fallback. Treat configuration, circuit-breaker, and invalid-proof errors as errors or retries.
6. On 200, pass ceremonyToken and the already-open popup to openPasskeyCeremony. Preserve requestID for proof binding.
7. A successful relay is not authentication by itself. Send resultToken and the opaque sign-in-attempt handle to /auth/akedly/passkey/complete; the backend loads requestID and identity from attempt state and creates a session only after accepting the proof.

The backend-signed `returnTarget: { origin: "https://your-site.example" }` is mandatory for authentication and must also be sent by OTP /verify for enrollment. A query or client-supplied returnUrl is untrusted. Without the signed target, the ceremony can succeed and be billed while the SDK fails closed with verified false and reason `no_proof`; deny access and reconcile GET /result or the correlated backend callback before starting OTP, or the user may be charged twice. For enrollment, that result is advisory and the next passkey sign-in confirms the credential.

After successful OTP verification, if data.enrollmentToken exists and isPasskeySupported() is true, offer immediate explicit opt-in and call openPasskeyCeremony(enrollmentToken); the token and enrollment window expire 120 seconds after OTP verification. On PASSKEY_TOKEN_EXPIRED or ENROLL_GATE_FAILED, keep the completed OTP session and ask the user to verify again before another enrollment attempt. A result with purpose "enroll" is non-blocking confirmation only: never send it to auth completion or create a session from it; the next sign-in proves the credential.

## Hosted passkey contract

The hosted page owns the token-authorized calls:

- POST /api/v1.2/transactions/passkey/auth-verify returns verified, transactionID, resultToken, and frontendCallbackURL.
- POST /api/v1.2/transactions/passkey/register-options accepts enrollmentToken and returns ceremonyToken and options.
- POST /api/v1.2/transactions/passkey/register-verify returns enrolled, credentialId, deviceLabel, and resultToken.
- GET /api/v1.2/transactions/passkey/ceremony resolves enrollment or ceremony tokens.
- GET /api/v1.2/transactions/passkey/result is the read-only auth recovery poll.

Do not implement browser-side WebAuthn or call these token-authorized endpoints from application code.

## resultToken verifier — implement exactly on the backend

- Fail closed before computing an HMAC if APIKey is missing, empty, or not a string.
- Require a string token beginning with `pkrt1.`. Remove the prefix, split on `.`, and require exactly two parts: payloadB64 and sigB64.
- Require BOTH segments to match the strict unpadded base64url regex `/^[A-Za-z0-9_-]+$/`.
- Decode both segments, re-encode each with unpadded base64url, and reject unless each re-encoded string exactly equals its input. Buffer.from(..., "base64url") is lenient, and non-canonical aliases can bypass a token-string single-use key.
- Compute HMAC-SHA256 over `pkrt1.${payloadB64}` using APIKey.
- Check signature byte lengths before crypto.timingSafeEqual, because it throws on a mismatch, then compare in constant time.
- Parse the payload and require `payload.verified === true`.
- Require a numeric exp and reject when `Date.now() > payload.exp`; timestamps are milliseconds.
- Require `payload.purpose === "auth"` and `payload.pipelineId === configured pipelineID`.
- Load the expected requestID and session identity from server-side attempt state keyed by the opaque handle; never accept those bindings from the client.
- Require `payload.transactionId === stored requestID`. Atomically consume the proof and attempt so a replay cannot create another session.

## Output

Generate runnable Express routes, the verifier, persistence interfaces for request binding and replay prevention, and a complete browser UI. Include phone and OTP inputs, passkey-first sign-in, enrollment opt-in, loading and error states, popup cleanup, and session creation only after backend proof acceptance. Use the exact @akedly/shield APIs solvePow, getTurnstileToken, isPasskeySupported, and openPasskeyCeremony.

Delta — add passkeys to an existing V1.2 integration

Add Akedly V1.2 passkeys to my existing working web OTP integration.

Do not modify the existing OTP flow except at the integration points listed.

Keep APIKey and pipelineID on the backend. Browser application requests must continue to call my backend; the only browser call to an Akedly host is the @akedly/shield ceremony at https://auth.akedly.io/pk.

## Integration points

1. Add POST /auth/akedly/passkey/auth-options on my backend. It accepts phoneNumber plus a fresh powSolution and optional turnstileToken, then calls:
   POST https://api.akedly.io/api/v1.2/transactions/passkey/auth-options
   with APIKey, pipelineID, verificationAddress: { phoneNumber }, powSolution, turnstileToken, and backend-configured `returnTarget: { origin: "https://your-site.example" }`.
   Use a bare origin with no trailing slash. Store ceremonyToken and requestID against the sign-in.
   Generate a cryptographically random attemptHandle. Persist its hash with requestID, ceremonyToken, configured pipelineID, stored identity, expiry, and unused state. Return data.attemptHandle alongside the unchanged upstream data, or bind the handle through a Secure, HttpOnly, SameSite cookie.
2. Before OTP, call isPasskeySupported(). When supported, open a blank popup synchronously inside the sign-in click, get and solve a fresh existing V1.2 challenge, obtain Turnstile when required, then call the new auth-options proxy.
3. On 404 with code "NO_PASSKEY" or 403 with code "PASSKEY_DISABLED", close the popup and resume the existing OTP flow with a new challenge, new PoW solution, and fresh Turnstile token when required. The speculative passkey call consumed the first PoW solution and any Turnstile token but created no request and consumed no passkey rate-limit slot. For passkey-specific 429 responses, honor retryAfter and offer a user-initiated OTP option rather than automatically triggering the paid fallback. Keep configuration, circuit-breaker, and invalid-proof errors on their normal error or retry path.
4. On 200, call openPasskeyCeremony(ceremonyToken, { popup }). Send resultToken and an opaque sign-in-attempt handle to a new backend completion route. The backend loads requestID and identity from attempt state; never create a session from client verified state alone.
5. Extend the existing backend OTP /verify upstream body with backend-configured `returnTarget: { origin: "https://your-site.example" }`. When the successful response contains enrollmentToken, offer immediate opt-in enrollment with openPasskeyCeremony(enrollmentToken); the token and enrollment window expire 120 seconds after OTP verification. On PASSKEY_TOKEN_EXPIRED or ENROLL_GATE_FAILED, keep the completed OTP session and ask the user to verify again. A purpose "enroll" result must never call auth completion or create a session; the next sign-in proves the credential.
6. Add a backend reconciliation route that loads ceremonyToken and expected requestID from server-side attempt state. On GET /result: verified must match stored requestID and completes only the stored identity; pending retries with bounded backoff and blocks OTP; failed or 200 expired atomically permits one fresh OTP fallback. On 410 PASSKEY_TOKEN_EXPIRED, never authorize from the unsigned callback or fall back automatically. A correlated success callback suppresses OTP and shows a recovery message; otherwise offer an explicit user-initiated "Use OTP instead" action that atomically closes the attempt and acknowledges the unrecoverable prior outcome.

The server-signed `returnTarget` is mandatory for both authentication and enrollment proof delivery. A client returnUrl is untrusted. Without it, a successful billed authentication fails closed as verified false with reason `no_proof`; deny access and reconcile GET /result or the backend callback BEFORE OTP fallback to avoid charging twice. Enrollment proof loss is advisory; the next passkey sign-in confirms the credential.

The hosted page—not my browser code—owns POST /api/v1.2/transactions/passkey/auth-verify, POST /api/v1.2/transactions/passkey/register-options, POST /api/v1.2/transactions/passkey/register-verify, and GET /api/v1.2/transactions/passkey/ceremony. Use GET /api/v1.2/transactions/passkey/result only for server-side auth reconciliation.

## resultToken verifier — implement exactly on the backend

- Fail closed before HMAC if APIKey is missing, empty, or not a string.
- Require `pkrt1.` and exactly two following dot-separated segments.
- Apply `/^[A-Za-z0-9_-]+$/` to both unpadded base64url segments.
- Decode and canonical-re-encode both segments; reject either mismatch to prevent alternate-string replay aliases.
- HMAC-SHA256 `pkrt1.${payloadB64}` with APIKey.
- Require equal signature lengths before crypto.timingSafeEqual, then compare in constant time.
- Require `payload.verified === true` and numeric, unexpired millisecond `payload.exp`.
- Require `payload.purpose === "auth"` and `payload.pipelineId === configured pipelineID`.
- Load the expected requestID and session identity from server-side attempt state keyed by the opaque handle; never accept those bindings from the client.
- Require `payload.transactionId === stored requestID`. Atomically consume the proof and attempt to enforce single use.

Install or update @akedly/shield and use isPasskeySupported and openPasskeyCeremony. Return a focused patch with only these integration points, tests for the new branches and verifier, and no rewrite of my OTP routes, components, state model, or error handling.

Customizing the Prompt

Add project context after the prompt:

  • Backend: framework, persistence layer, session model, and replay-store implementation
  • Frontend: navigation, state management, UI system, and error presentation
  • Relay target: production web origin, native callback scheme, or React Native fallback choice
  • Testing: unit, integration, and end-to-end test conventions

Was this page helpful?