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

passkey-magic

v0.4.0

Published

Passkey-first authentication with QR cross-device login and magic link fallback

Readme

passkey-magic

Passkey-first authentication with QR cross-device login and magic link fallback.

  • Passkeys (WebAuthn) — Register and sign in with biometrics, security keys, or platform authenticators
  • QR Cross-Device — Scan a QR code on your phone to log in on desktop
  • Magic Links — Email-based passwordless fallback
  • Framework Agnostic — Works with any JavaScript runtime (Node.js, Bun, Deno, Cloudflare Workers)
  • better-auth Plugin — Drop-in integration with better-auth

Install

npm install passkey-magic

For production deployment guidance, see SECURITY.md and RELEASE.md.

Quick Start

Server

import { createAuth } from 'passkey-magic/server'
import { memoryAdapter } from 'passkey-magic/adapters/memory'

const auth = createAuth({
  rpName: 'My App',
  rpID: 'example.com',
  origin: 'https://example.com',
  storage: memoryAdapter(),
  rateLimit: {
    rules: {
      'magicLink.send': { limit: 5, windowMs: 15 * 60 * 1000 },
    },
  },
})

// Use as a Web Standard Request handler
export default {
  fetch: auth.createHandler({ pathPrefix: '/auth' })
}

// Grouped API is the recommended default
const { userId, options } = await auth.passkeys.register.start({
  email: '[email protected]',
})

Client

import { createClient } from 'passkey-magic/client'

const auth = createClient({
  request: (endpoint, body) =>
    fetch(`/auth${endpoint}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: body ? JSON.stringify(body) : undefined,
    }).then(r => r.json()),
})

// Register a passkey
const { user, session } = await auth.passkeys.register({ email: '[email protected]' })

// Sign in
const result = await auth.passkeys.signIn()

Client ergonomics helpers are also available:

import { AuthClientError, createClient } from 'passkey-magic/client'

const auth = createClient({ request })

try {
  await auth.accounts.get()
} catch (error) {
  if (error instanceof AuthClientError) {
    console.error(error.status, error.message)
  }
}

const session = await auth.waitForQRSession(sessionId, statusToken)
const token = auth.extractMagicLinkToken(window.location.href)
await auth.verifyMagicLinkURL({ url: window.location.href })

const flow = await auth.qr.createFlow({
  urlBuilder: (sessionId) => `https://example.com/auth/qr/${sessionId}`,
})

const unsubscribe = auth.observeSession((current) => {
  console.log('session changed', current)
}, { intervalMs: 30_000 })

const method = await auth.getBestSignInMethod()

const controller = new AbortController()
await auth.requestMagicLink({ email: '[email protected]' }, { signal: controller.signal })

Features

Identity Model

passkey-magic treats User as the canonical account record.

  • Passkey registration can create a user without an email.
  • Magic link verification can create a user with an email.
  • Email is optional overall and can be linked later with linkEmail().
  • Signing in never merges accounts implicitly.
  • QR completion authenticates an existing mobile user into a desktop session; it does not create a new identity on its own.

That means the library supports three common shapes cleanly:

  • passkey-only accounts
  • email-only accounts created through magic links
  • accounts that start with one method and attach the other later

If a magic link is verified for an email that already belongs to a user, the existing user is signed in. If an email is already linked to a different user, linkEmail() fails instead of merging accounts.

Passkey Authentication

The grouped API is the recommended primary surface:

const { userId, options } = await auth.passkeys.register.start({
  email: '[email protected]',
})

const result = await auth.passkeys.register.finish({
  userId,
  response: browserResponse,
})

const { options: signInOptions } = await auth.passkeys.signIn.start()
const signIn = await auth.passkeys.signIn.finish({ response: browserResponse })

The low-level WebAuthn methods are still available for advanced integrations:

const { options, userId } = await auth.generateRegistrationOptions({ email: '[email protected]' })
const result = await auth.verifyRegistration({ userId, response: browserResponse })

QR Cross-Device Login

QR login is modeled as a short-lived state machine. Sessions move through created, scanned, challenged, authenticated, expired, or cancelled.

Polling stops automatically once the session reaches authenticated, expired, or cancelled.

Security model:

  • sessionId inside the QR code is a bearer capability for attempting mobile completion.
  • statusToken is a separate desktop-only secret used for polling and cancellation.
  • Possession of sessionId alone does not reveal desktop status, but it does allow a scanner to attempt authentication into that desktop flow.
  • In other words, whoever scans the QR first can try to complete login on the desktop if they can also satisfy mobile authentication.

For stronger protection, you can enable an optional short confirmation code:

const auth = createAuth({
  // ...other config
  qrConfirmation: {
    enabled: true,
    codeLength: 6,
  },
})

This is acceptable for many QR login designs, but it is not equivalent to strong desktop/mobile device binding. If you need stronger protection against QR capture or confused-deputy style flow hijacking, add a desktop confirmation step or short approval code in your application UX.

// Desktop: create session and display QR code
const { sessionId, statusToken, confirmationCode } = await auth.qr.create()
const qrSvg = auth.qr.render(`https://example.com/auth/qr/${sessionId}`)

// Desktop: poll for completion
for await (const status of auth.qr.poll(sessionId, statusToken)) {
  if (status.state === 'authenticated') {
    // User logged in from their phone
  }
}

// Mobile: complete the session
await auth.qr.complete({ sessionId, confirmationCode })

// Optional: cancel an in-flight QR login
await auth.qr.cancel({ sessionId, statusToken })

Operational guidance:

  • rate limit QR create, scan, and complete endpoints
  • keep QR session TTLs short
  • do not log statusToken
  • treat the QR code itself as sensitive until scanned or expired

Magic Links

Enable by providing an email adapter:

const auth = createAuth({
  // ...webauthn config
  storage: memoryAdapter(),
  email: {
    async sendMagicLink(email, url, token) {
      await sendEmail({ to: email, subject: 'Login', html: `<a href="${url}">Log in</a>` })
    }
  },
  magicLinkURL: 'https://example.com/auth/verify',
})

// Send a magic link
await auth.magicLinks.request({ email: '[email protected]' })

// Verify (after user clicks the link)
const { user, session, isNewUser } = await auth.magicLinks.verify({ token })

Passkey Management

// Add a passkey to an existing account
const { options } = await auth.passkeys.add.start({ userId })
const { credential } = await auth.passkeys.add.finish({ userId, response: browserResponse })

// List, update, remove
const credentials = await auth.passkeys.list(userId)
await auth.passkeys.update({ credentialId: 'cred_123', label: 'iPhone' })
await auth.passkeys.remove('cred_123')

Both users and passkeys can also carry JSON metadata.

await auth.accounts.updateMetadata({
  userId,
  metadata: { plan: 'pro', onboardingComplete: true },
})

await auth.passkeys.update({
  credentialId: 'cred_123',
  metadata: { nickname: 'Work MacBook', platform: 'macos' },
})

Accounts And Identity

const user = await auth.accounts.get(userId)
const sameUser = await auth.accounts.getByEmail('[email protected]')

const canLink = await auth.accounts.canLinkEmail({
  userId,
  email: '[email protected]',
})

if (canLink.ok) {
  await auth.accounts.linkEmail({ userId, email: '[email protected]' })
}

await auth.accounts.unlinkEmail({ userId })

On the client, the same account workflow is available for the current authenticated user:

const profile = await auth.accounts.get()
const canLink = await auth.accounts.canLinkEmail('[email protected]')

if (canLink.ok) {
  await auth.accounts.linkEmail('[email protected]')
}

Typed Metadata

You can thread metadata types through both server and client APIs.

type UserMeta = {
  theme: 'light' | 'dark'
}

type CredentialMeta = {
  nickname: string
}

const auth = createAuth<UserMeta, CredentialMeta>({
  rpName: 'My App',
  rpID: 'example.com',
  origin: 'https://example.com',
  storage: memoryAdapter<UserMeta, CredentialMeta>(),
})

await auth.accounts.updateMetadata({
  userId: 'user_123',
  metadata: { theme: 'dark' },
})

await auth.passkeys.update({
  credentialId: 'cred_123',
  metadata: { nickname: 'Work MacBook' },
})

The better-auth plugin also accepts the same metadata generics:

const plugin = passkeyMagicPlugin<UserMeta, CredentialMeta>({
  rpName: 'My App',
  rpID: 'example.com',
  origin: 'https://example.com',
})

Session Management

const result = await auth.validateSession(token)  // { user, session } | null

if (result) {
  result.session.authMethod // 'passkey' | 'magic-link' | 'qr'
}

const sessions = await auth.getUserSessions(userId)
await auth.revokeSession(token)
await auth.revokeAllSessions(userId)

On the client, session validation uses the authenticated request transport directly:

const current = await auth.getSession()

Rate Limiting

Sensitive public routes are rate-limited by default with an in-memory limiter.

For production, prefer a shared limiter implementation across instances.

import { createAuth, createMemoryRateLimiter, createUnstorageRateLimiter } from 'passkey-magic/server'
import { createStorage } from 'unstorage'

const auth = createAuth({
  // ...auth config
  rateLimit: {
    limiter: createMemoryRateLimiter(),
    rules: {
      'magicLink.send': { limit: 5, windowMs: 15 * 60 * 1000 },
      'email.available': null, // disable if you handle this elsewhere
    },
  },
})

const sharedLimiter = createUnstorageRateLimiter(createStorage())

const prodAuth = createAuth({
  // ...auth config
  rateLimit: {
    limiter: sharedLimiter,
  },
})

If you use the better-auth plugin, you can pass the same rateLimit config there too.

When used as a Better Auth plugin, passkey-magic also exposes Better Auth-native plugin rateLimit rules for sensitive plugin endpoints when you configure rateLimit.rules.

better-auth Cookies And Deployment

The Better Auth integration creates real Better Auth sessions and writes cookies through Better Auth's cookie system. That means Better Auth cookie settings apply to this plugin too, including:

  • cookie prefixes and custom cookie names
  • secure cookie behavior
  • cross-subdomain cookie settings
  • Safari/ITP deployment constraints

Recommended setup:

  • keep frontend and Better Auth endpoints on the same site when possible
  • use a reverse proxy or a shared parent domain for Safari compatibility
  • configure Better Auth advanced.crossSubDomainCookies only when needed
  • use Better Auth secure cookie settings in production

Secret storage notes:

  • magic-link bearer tokens are stored hashed at rest
  • QR statusToken values are stored hashed at rest
  • optional QR confirmation codes are stored hashed at rest

Example:

import { betterAuth } from 'better-auth'
import { passkeyMagicPlugin } from 'passkey-magic/better-auth'
import { createUnstorageRateLimiter } from 'passkey-magic/server'
import { createStorage } from 'unstorage'

const auth = betterAuth({
  trustedOrigins: ['https://app.example.com'],
  advanced: {
    useSecureCookies: true,
    crossSubDomainCookies: {
      enabled: true,
      domain: 'example.com',
    },
  },
  plugins: [
    passkeyMagicPlugin({
      rpName: 'My App',
      rpID: 'example.com',
      origin: 'https://app.example.com',
      rateLimit: {
        limiter: createUnstorageRateLimiter(createStorage()),
        rules: {
          'magicLink.send': { limit: 5, windowMs: 15 * 60 * 1000 },
        },
      },
    }),
  ],
})

Focused Better Auth QR Plugin

If you already use Better Auth's own passkey and magic-link features and only want the QR cross-device layer, use the focused additive QR submodule:

import { betterAuth } from 'better-auth'
import { passkeyMagicQRPlugin } from 'passkey-magic/better-auth/qr'

const auth = betterAuth({
  plugins: [
    passkeyMagicQRPlugin({
      rpName: 'My App',
      rpID: 'example.com',
      origin: 'https://app.example.com',
    }),
  ],
})

And on the client:

import { createAuthClient } from 'better-auth/client'
import { passkeyMagicQRClientPlugin } from 'passkey-magic/better-auth/qr/client'

const authClient = createAuthClient({
  plugins: [passkeyMagicQRClientPlugin()],
})

Use this focused QR submodule when:

  • Better Auth already handles your passkeys
  • Better Auth already handles your magic links or other account flows
  • you only want the QR session state machine and cross-device login UX from this library

Use passkey-magic/better-auth instead when you want the broader integration layer from this package.

Production Checklist

Before shipping this in production:

  • use persistent storage, not memoryAdapter()
  • use a shared rate limiter across instances
  • configure a real email delivery provider
  • run behind HTTPS only
  • set exact rpID and origin values for your deployed domains
  • harden cookies/sessions in the host app
  • monitor auth failures, magic-link delivery, and rate-limit events
  • decide whether email-available should be exposed publicly at all

Publishing

  • CHANGELOG.md tracks notable changes
  • SECURITY.md documents reporting and deployment guidance
  • RELEASE.md contains a release checklist
  • prepublishOnly runs tests and build before publishing

Lifecycle Hooks

const auth = createAuth({
  // ...config
  hooks: {
    async beforeRegister({ email }) {
      if (await isBlocked(email)) return false // abort
    },
    async afterAuthenticate({ user, session }) {
      await logLogin(user.id)
    },
  },
})

Events

auth.on('session:created', ({ session, user, method }) => { /* ... */ })
auth.on('credential:created', ({ credential, user }) => { /* ... */ })
auth.on('user:created', ({ user }) => { /* ... */ })

Storage Adapters

Memory (development)

import { memoryAdapter } from 'passkey-magic/adapters/memory'

const storage = memoryAdapter()

Unstorage (production)

Works with any unstorage driver (Redis, Vercel KV, Cloudflare KV, filesystem, etc.):

import { unstorageAdapter } from 'passkey-magic/adapters/unstorage'
import { createStorage } from 'unstorage'
import redisDriver from 'unstorage/drivers/redis'

const storage = unstorageAdapter(
  createStorage({ driver: redisDriver({ url: 'redis://localhost:6379' }) }),
  { base: 'auth' }
)

Custom Adapter

Implement the StorageAdapter interface for any database:

import type { StorageAdapter } from 'passkey-magic/server'

const myAdapter: StorageAdapter = {
  createUser(user) { /* ... */ },
  getUserById(id) { /* ... */ },
  // ... see StorageAdapter interface for all methods
}

Integrations

Nitro

import { passkeyMagic, useAuth } from 'passkey-magic/nitro'

export default defineNitroPlugin(() => {
  passkeyMagic({
    rpName: 'My App',
    rpID: 'example.com',
    origin: 'https://example.com',
    pathPrefix: '/auth',
  }).setup(nitroApp)
})

// In route handlers:
const auth = useAuth()
const session = await auth.validateSession(token)

better-auth

Use passkey-magic as a better-auth plugin. All data is stored in better-auth's database, and sessions are unified with better-auth's session system.

Server

import { betterAuth } from 'better-auth'
import { passkeyMagicPlugin } from 'passkey-magic/better-auth'

const auth = betterAuth({
  database: myAdapter,
  plugins: [
    passkeyMagicPlugin({
      rpName: 'My App',
      rpID: 'example.com',
      origin: 'https://example.com',
    }),
  ],
})

Client

import { createAuthClient } from 'better-auth/client'
import { passkeyMagicClientPlugin } from 'passkey-magic/better-auth/client'

const auth = createAuthClient({
  plugins: [passkeyMagicClientPlugin()],
})

// All endpoints are type-safe:
await auth.passkeyMagic.register.options({ email: '[email protected]' })
await auth.passkeyMagic.qr.create()

Plugin Endpoints

All endpoints are prefixed with /passkey-magic/:

| Endpoint | Method | Auth | Description | |---|---|---|---| | /register/options | POST | No | Generate passkey registration options | | /register/verify | POST | No | Verify registration and create session | | /authenticate/options | POST | No | Generate authentication options | | /authenticate/verify | POST | No | Verify authentication and create session | | /add/options | POST | Yes | Add passkey to existing account | | /add/verify | POST | Yes | Verify added passkey | | /credentials | GET | Yes | List user's passkeys | | /credentials/update | POST | Yes | Update passkey label | | /credentials/remove | POST | Yes | Remove a passkey | | /qr/create | POST | No | Create QR login session | | /qr/status | GET | No | Poll QR session status | | /qr/scanned | POST | No | Mark QR session as scanned | | /qr/complete | POST | No | Complete QR auth and create session | | /magic-link/send | POST | No | Send magic link email | | /magic-link/verify | POST | No | Verify magic link and create session |

The plugin creates 4 database tables (passkeyCredential, qrSession, passkeyChallenge, magicLinkToken) and manages them through better-auth's adapter. Authentication endpoints create proper better-auth sessions with cookies.

Configuration

interface AuthConfig {
  rpName: string              // Relying party name (shown in passkey prompts)
  rpID: string                // Relying party ID (your domain)
  origin: string | string[]   // Expected origin(s) for WebAuthn
  storage: StorageAdapter     // Persistence layer
  email?: EmailAdapter        // Enables magic links
  magicLinkURL?: string       // Base URL for magic link emails
  sessionTTL?: number         // Default: 7 days (ms)
  challengeTTL?: number       // Default: 60 seconds (ms)
  magicLinkTTL?: number       // Default: 15 minutes (ms)
  qrSessionTTL?: number       // Default: 5 minutes (ms)
  generateId?: () => string   // Default: crypto.randomUUID()
  hooks?: AuthHooks           // Lifecycle hooks
}

License

MIT