npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@vc1023/passkey-2fa

v0.3.0

Published

Drop-in password + passkey (WebAuthn) + authenticator (TOTP) 2FA for Next.js App Router + Supabase.

Downloads

570

Readme

@vc1023/passkey-2fa

Drop-in password + passkey (WebAuthn) 2FA for Next.js App Router + Supabase.

  • Email + password = first factor (Supabase Auth, AAL1)
  • A passkey = mandatory second factor (custom WebAuthn, AAL2), enforced server-side
  • An authenticator app (TOTP) = optional backup second factor (Supabase-native MFA) — both factors mint the same AAL2 session (since 0.3.0)
  • Single-use expiring challenges · replay-protected counter · session-bound AAL2 cookie · per-route rate limiting · fail-loud config

It ships server route-handler factories, an Edge middleware factory, browser helpers, and the SQL migration. Audit/analytics stay yours via an onEvent hook.

1. Install

npm install @vc1023/passkey-2fa

Add it to transpilePackages (it ships TypeScript source):

// next.config.ts
const nextConfig = { transpilePackages: ["@vc1023/passkey-2fa"] };

2. Environment

Copy node_modules/@vc1023/passkey-2fa/.env.example into .env.local and fill it. Then verify:

npx passkey-2fa check-env

| Var | Where | | --- | --- | | NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY / SUPABASE_SERVICE_ROLE_KEY | Supabase → Settings → API | | WEBAUTHN_ORIGIN (https://yourapp.com) · WEBAUTHN_RP_ID (yourapp.com) · WEBAUTHN_RP_NAME | your app's domain | | AUTH_MFA_SECRET | openssl rand -hex 32 |

In production these are required and validated (origin must be https; RP-ID must equal the origin host) — the app fails loud if any is missing. In dev they default to localhost.

Supabase setting: disable email confirmation (Auth → Email) so the user is signed in immediately and can enroll a passkey in the same sign-up flow.

3. Database

Apply the migration to your Supabase project (SQL editor or supabase db push):

node_modules/@vc1023/passkey-2fa/migrations/0001_passkey_tables.sql

4. Mount the route handlers

Create one file per endpoint under app/api/auth/…, all delegating to a shared instance:

// app/lib/auth.ts
import { createPasskeyAuthHandlers } from "@vc1023/passkey-2fa/routes";

export const handlers = createPasskeyAuthHandlers({
  // optional: audit / analytics / funnel — never required
  onEvent: async (e) => { /* e.type: "signup" | "signin_success" | "mfa_enrolled" | … */ },
});
// app/api/auth/sign-up/route.ts
import { handlers } from "@/app/lib/auth";
export const runtime = "nodejs";
export const POST = handlers.signUp;

Repeat for: sign-inhandlers.signIn, sign-outhandlers.signOut, webauthn/register/optionshandlers.registerOptions, webauthn/register/verifyhandlers.registerVerify, webauthn/authenticate/optionshandlers.authenticateOptions, webauthn/authenticate/verifyhandlers.authenticateVerify.

Authenticator-app (TOTP) backup factor — optional (since 0.3.0)

Mount these for a backup second factor (no DB migration — Supabase owns the MFA tables): totp/enroll/starthandlers.totpEnrollStart, totp/enroll/verifyhandlers.totpEnrollVerify, totp/challenge/verifyhandlers.totpChallengeVerify, totp/unenrollhandlers.totpUnenroll, factorshandlers.factorsList.

Client helpers: startTotpEnroll(), verifyTotpEnroll(factorId, code), signInWithTotp(code), getFactors(), removeTotp() from @vc1023/passkey-2fa/client. A verified TOTP code mints the same AAL2 session as the passkey. Enable TOTP MFA in your Supabase project (Auth → it's on by default).

5. Middleware

// middleware.ts
import { createPasskeyMiddleware } from "@vc1023/passkey-2fa/middleware";

export const middleware = createPasskeyMiddleware({ protectedPaths: ["/dashboard"] });
export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)"],
};

6. Protect a page (server-side AAL2 gate)

// app/dashboard/page.tsx
import { requireAal2, getSessionUser } from "@vc1023/passkey-2fa";
export const dynamic = "force-dynamic";

export default async function Dashboard() {
  await requireAal2();            // redirects to /sign-in unless fully AAL2
  const user = await getSessionUser();
  return <p>Signed in as {user?.email}</p>;
}

7. Build your UI with the client helpers

You own the screens/copy; the package gives the network + ceremony:

"use client";
import {
  signUp, enrollPasskey, signIn, challengePasskey, signOut, browserSupportsPasskeys,
} from "@vc1023/passkey-2fa/client";

// sign-up: await signUp(email, password) → if ok, await enrollPasskey()
// sign-in: await signIn(email, password) → if ok, await challengePasskey()
// each ceremony returns { ok:true } | { ok:false, reason:"cancelled"|"unsupported"|"error" }

Distributed rate limiting (optional)

The default limiter is in-memory, per-instance, and fixed-window (fine for one instance; not shared across serverless instances/regions, and allows up to ~2× the limit across a window boundary). For multi-instance production, inject a distributed sliding-window RateLimiter — e.g. Upstash Redis:

// app/lib/auth.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { createPasskeyAuthHandlers, type RateLimiter } from "@vc1023/passkey-2fa/routes";

const redis = Redis.fromEnv(); // UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN
const cache = new Map<string, Ratelimit>();

const rateLimit: RateLimiter = async (key, limit, windowMs) => {
  const id = `${limit}:${windowMs}`;
  let rl = cache.get(id);
  if (!rl) {
    rl = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(limit, `${windowMs} ms`), prefix: "pk2fa" });
    cache.set(id, rl);
  }
  const r = await rl.limit(key);
  return { ok: r.success, retryAfterSeconds: Math.max(0, Math.ceil((r.reset - Date.now()) / 1000)) };
};

export const handlers = createPasskeyAuthHandlers({ rateLimit /*, onEvent */ });

RateLimiter is (key, limit, windowMs) => RateLimitResult | Promise<RateLimitResult> — the per-endpoint limit/window are passed in, so one implementation serves every route.

Notes

  • Route handlers run on runtime = "nodejs" (the AAL2 token uses node:crypto). The middleware is Edge-safe.
  • The AAL2 session is bound to the Supabase session id (fail-closed): a stolen AAL2 cookie can't elevate a different session.
  • Server validation is enforced; you may also signUpSchema.safeParse() client-side for instant feedback.