Skip to content
cccskills
FreePublished on GitHub

passkeys

Implement passkey signup, sign-in, and enrollment for signed-in users with @auth0/nextjs-auth0 v4. The skill explains how server handlers create challenges and exchange credentials, while the browser performs WebAuthn and serializes the result.

OriginalShow the original description

Passkey (WebAuthn) flows with @auth0/nextjs-auth0 v4: signup, sign-in, and My Account enrollment using the server-side auth0.passkey.* API inside App Router route handlers or server actions, with navigator.credentials and serializeCredential in the browser.

auth0/nextjs-auth02,312 stars(GitHub 2,312 + cccskills 0)

Run score Not tested

See how to install

Good for

  • Adding passkey signup to a Next.js application
  • Building passkey sign-in with browser WebAuthn
  • Letting signed-in users enroll another passkey
  • Handling verification codes during passkey signup

Files included (1)

  • SKILL.md11.5 KB

SKILL.md (original)

Review the instructions the agent will receive before you install.

@auth0/nextjs-auth0 — Passkeys (v4)

Minimum version: 4.22.0 (passkey signup/login + My Account enrollment).

Covers the three passkey flows using the server-side auth0.passkey.* API inside your own route handlers / server actions. The token exchange and client secret stay on the server; only the WebAuthn call runs in the browser.

For tenant setup (custom domain, passkey grant, connection auth method) see the Auth0 skill hub (feature:passkeys).

Two layers — use the server API here

This SDK ships two ways to build passkeys:

  • Server methods (auth0.passkey.*) — you own the route handlers / server actions. Use when the task asks you to drive the ceremony yourself. This is what this skill covers.
  • Client one-call wrappers (passkey.signup() / passkey.login() from @auth0/nextjs-auth0/client) — shortcut that runs the whole flow in one call. Convenient, but gives you no control over individual steps.

The ceremony shape (all three flows)

Every flow follows: server challenge → browser WebAuthn → serialize → server exchange.

Signup

// server (route handler or server action)
import { auth0 } from "@/lib/auth0";

const { authSession, authnParamsPublicKey } = await auth0.passkey.register({
  email,
  name,
  // also accepts: username, phoneNumber, givenName, familyName, nickname,
  //               picture, userMetadata, connection, organization
}); // → PasskeyRegisterResponse
// Send authSession + authnParamsPublicKey to the browser
"use client";
import { serializeCredential } from "@auth0/nextjs-auth0/client";

// authnParamsPublicKey has base64url binary fields — decode to ArrayBuffers first.
// The SDK does NOT export a decoder, so write a small helper:
function b64urlToBuf(s: string): ArrayBuffer {
  const b = atob(s.replace(/-/g, '+').replace(/_/g, '/').padEnd(
    s.length + (4 - (s.length % 4)) % 4, '='));
  return Uint8Array.from(b, c => c.charCodeAt(0)).buffer;
}

const publicKey: PublicKeyCredentialCreationOptions = {
  ...authnParamsPublicKey,
  challenge: b64urlToBuf(authnParamsPublicKey.challenge),
  user: { ...authnParamsPublicKey.user, id: b64urlToBuf(authnParamsPublicKey.user.id) },
  excludeCredentials: authnParamsPublicKey.excludeCredentials?.map(c => ({
    ...c, id: b64urlToBuf(c.id),
  })),
};
const credential = await navigator.credentials.create({ publicKey });
if (!credential) throw new Error("No passkey was created"); // can be null
const authResponse = serializeCredential(credential as PublicKeyCredential);
// POST { authSession, authResponse } back to a server action / route
// server: exchange credential for a session
await auth0.passkey.getToken({ authSession, authResponse }); // → Promise<void>

Signup with identifier verification (OTP)

Note: Identifier verification on passkey signup is in Early Access. Please contact Auth0 Support to get it enabled for your tenant.

If the connection requires email or phone verification, register() returns verificationRequired (e.g. ["email"]). Collect the one-time codes before navigator.credentials.create, then pass them to getToken as verification:

The OTP screen and the passkey prompt run in the browser between the two server calls, so use two Server Actions called from a "use client" component. The browser holds authSession and the serialized authResponse between calls.

"use server";
// Both actions return plain objects: error properties thrown from a Server Action do not reach the browser.
// Action 1: challenge = { authSession, authnParamsPublicKey, verificationRequired? }
export async function startSignup(email: string, phoneNumber?: string) {
  try {
    // deliveryMethod ("text" | "voice") is the phone code channel: send it only with phoneNumber
    const challenge = await auth0.passkey.register({ email, ...(phoneNumber && { phoneNumber, deliveryMethod: "text" as const }) });
    return { ok: true as const, challenge };
  } catch (err) {
    if (err instanceof PasskeyRegisterError) return { ok: false as const, error: err.error }; // e.g. too_many_requests
    throw err;
  }
}

// Action 2
export async function completeSignup(authSession: string, authResponse: PasskeyAuthResponse, verification?: PasskeyVerificationCodes) {
  try {
    await auth0.passkey.getToken({ authSession, authResponse, verification });
    return { ok: true as const };
  } catch (err) {
    if (err instanceof MfaRequiredError) {
      // codes accepted, MFA needed. mfa_token is SDK-encrypted, safe to return; the browser
      // continues with the client `mfa` helper (getAuthenticators/challenge/verify), which creates the session
      return { ok: false as const, error: "mfa_required", mfaToken: err.mfa_token };
    }
    if (err instanceof PasskeyGetTokenError) {
      return { ok: false as const, error: err.error, retryable: err.isRetryable, authSession: err.authSession, verificationRequired: err.verificationRequired };
    }
    throw err;
  }
}

Client component order: startSignup, then show OTP inputs for verificationRequired, then navigator.credentials.create once, then completeSignup(authSession, authResponse, codes) with one code per identifier in verificationRequired (e.g. { email, phone }). If retryable, re-prompt for verificationRequired and call completeSignup again with the returned authSession (falls back to the original) + the SAME authResponse, sending codes only for identifiers still listed (no second biometric prompt). Otherwise a new code will not help: on too_many_requests wait and resubmit, on a terminal session restart from startSignup. On mfa_required, run mfa.getAuthenticators/challenge/verify({ mfaToken }) in the browser.

With the client wrapper, pass onVerificationRequired: async ({ verificationRequired, isRetry }) => codes to passkey.signup(), returning one code per listed identifier. It runs before the passkey prompt and again on a wrong code.

Sign-in

Same shape — replace register with challenge, and navigator.credentials.create with .get (decode challenge + each allowCredentials[].id):

// server
const { authSession, authnParamsPublicKey } = await auth0.passkey.challenge(); // → PasskeyChallengeResponse
// browser: decode + navigator.credentials.get + serializeCredential → authResponse
const publicKey: PublicKeyCredentialRequestOptions = {
  ...authnParamsPublicKey,
  challenge: b64urlToBuf(authnParamsPublicKey.challenge),
  allowCredentials: authnParamsPublicKey.allowCredentials?.map(c => ({
    ...c, id: b64urlToBuf(c.id),
  })),
};
const credential = await navigator.credentials.get({ publicKey });
if (!credential) throw new Error("No passkey was selected"); // can be null
const authResponse = serializeCredential(credential as PublicKeyCredential);
// POST { authSession, authResponse } back to server
// server
await auth0.passkey.getToken({ authSession, authResponse });

Enrollment (signed-in user — adds a passkey to the current account)

Uses the My Account API internally. Requires an MRRT policy on the tenant so the SDK can mint the create:me:authentication_methods token from the session refresh token.

// server
const { authenticationMethodId, authSession, authnParamsPublicKey } =
  await auth0.passkey.enrollmentChallenge(); // → PasskeyEnrollmentChallengeResponse
// Send authenticationMethodId + authSession + authnParamsPublicKey to the browser
// browser: same decode + navigator.credentials.create + null check + serializeCredential as signup
const authResponse = serializeCredential(credential as PublicKeyCredential);
// POST { authenticationMethodId, authSession, authResponse } back to server
// server
await auth0.passkey.enrollmentVerify({ authenticationMethodId, authSession, authResponse });
// → PasskeyEnrollmentVerifyResponse: the registered auth method { id, type: "passkey", ... }

API reference

MethodSignatureReturns
auth0.passkey.register(options?)PasskeyRegisterOptions? (incl. deliveryMethod?: "text" | "voice", phone code channel, send only with phoneNumber)PasskeyRegisterResponse: { authSession, authnParamsPublicKey, verificationRequired? }
auth0.passkey.challenge(options?)PasskeyChallengeOptions?PasskeyChallengeResponse — { authSession, authnParamsPublicKey }
auth0.passkey.getToken(options){ authSession, authResponse, connection?, organization?, scope?, audience?, verification? }Promise<void>: sets session
auth0.passkey.enrollmentChallenge(options?){ connection?, userIdentityId? }?PasskeyEnrollmentChallengeResponse — { authenticationMethodId, authSession, authnParamsPublicKey }
auth0.passkey.enrollmentVerify(options){ authenticationMethodId, authSession, authResponse }PasskeyEnrollmentVerifyResponse
serializeCredential(credential)PublicKeyCredentialPasskeyAuthResponse — JSON-safe, base64url-encoded

Pages Router / Middleware overloads: register(req, options?), challenge(req, options?), enrollmentChallenge(req, options?), enrollmentVerify(req, options) take req: NextRequest; getToken(req, res, options) needs both NextRequest and NextResponse (wrong arity throws TypeError).

Key gotchas

  • getToken credential field is authResponse, not credential.
  • No public decoder for authnParamsPublicKey — the SDK doesn't export one; write the tiny base64url helper shown above. Do not use @simplewebauthn — it double-encodes and breaks the token exchange.
  • Confidential client required — the server token exchange authenticates with the client secret.
  • Custom domain required: passkeys will not work on the default *.auth0.com domain. The custom domain becomes the passkey rpId, and the app must be served from that domain or a subdomain of it (also in local dev; plain localhost fails the WebAuthn ceremony).
  • Enrollment requires MRRT — without the Multi-Resource Refresh Token policy, enrollmentChallenge cannot mint the My Account access token.
  • Never spread a request body into register() / getToken() from a Server Action or route: the browser could set connection, organization, userMetadata, audience or scope. Pass only validated fields.
  • verification keys are email / phone, not phoneNumber. Other keys or non-string codes throw InvalidRequestError before any request.
  • Only invalid_grant with authSession is retryable (err.isRetryable, also is_retryable in the /auth/passkey/get-token error body). invalid_request (missing code) is NOT retryable: send a code for every identifier. Never branch on status or error_description. On retry reuse the same authResponse; never re-run navigator.credentials.create.
  • getToken can throw mfa_required: continue with the client mfa helper (mfa/SKILL.md) using the mfa_token. The user has no session yet, so mfa.verify() (route /auth/mfa/verify) creates it; a step-up MFA page or server auth0.mfa.verify() fails with no session.
  • Do not call the server register/challenge methods from a client component — they are server-only.

Reviews

No reviews yet. Tell others what you thought.

Comments

No login needed. Share how it worked for you, ask a question, or note where it did not run.

No comments yet. Be the first to write one.

Comments are published right away. Do not include personal information such as email addresses or phone numbers. Up to 2 URLs.

0 / 1,000 characters

This form is protected by reCAPTCHA, and the Google Privacy Policy and Terms of Service apply.

More skills from this repository

mfa

FreeSummary

Build multi-factor authentication flows with @auth0/nextjs-auth0 v4. The skill covers redirect and server-triggered step-up checks, managing authenticators, and popup challenges, including how to pass MFA tokens safely and handle common errors.

  • Requiring MFA before a sensitive action
  • Redirecting users to complete an MFA challenge
  • Enrolling or verifying an authenticator
auth0/nextjs-auth02.3KUpdated Oct 26, 2026

nextjs-auth0

FreeSummary

Set up Auth0 authentication in Next.js with @auth0/nextjs-auth0 v4 for App Router or Pages Router. Configure the client and middleware, add login and user context, protect pages and API routes, and retrieve sessions or access tokens for API calls.

  • Adding Auth0 login to a Next.js application
  • Configuring middleware and authentication routes
  • Protecting pages and API endpoints
auth0/nextjs-auth02.3KUpdated Oct 26, 2026

organizations

FreeSummary

Use this skill to build B2B authentication for multiple customer organizations with Auth0 and @auth0/nextjs-auth0 v4. It covers passing an organization at login, reading and validating its ID, accepting invitations, and configuring organizations and connections.

  • Separating login and access by customer organization
  • Passing an organization ID through a login flow
  • Accepting an organization invitation in a Next.js app
auth0/nextjs-auth02.3KUpdated Oct 26, 2026

See all skills by auth0

Report a problem with this skill