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

@anephenix/fastify-auth

v0.0.4

Published

Fastify plugin for @anephenix/auth — drop-in route registration for password, magic-link, SMS MFA, TOTP MFA, and password-reset strategies

Readme

@anephenix/fastify-auth

Node.js CI

A Fastify 5 plugin that wires up authentication routes for your app using @anephenix/auth and your own ORM models (Objection.js or anything that satisfies the model interfaces).

Choose a strategy and the plugin registers the matching HTTP routes, handles token generation and verification, and delegates side-effects (sending emails, SMS) to hooks you provide.

Contents

Install

npm i @anephenix/fastify-auth

Peer dependencies (install separately if not already present):

npm i @anephenix/auth fastify

For cookie-based auth (web clients):

npm i @fastify/cookie

Prerequisites

If you use web-client cookie support, register @fastify/cookie before registering this plugin:

import fastifyCookie from '@fastify/cookie';
await app.register(fastifyCookie);
await app.register(authPlugin, { ... });

Quick start

import authPlugin from '@anephenix/fastify-auth';
import { Auth } from '@anephenix/auth';

const auth = new Auth({ /* your Auth config */ });

app.register(authPlugin, {
  strategy: 'sessions',
  auth,
  models: { User, Session },
});

Strategies

sessions

Password-based login with full session management. Tokens are returned in the response body for API clients, or set as HttpOnly cookies for web clients (see Web vs API clients).

Required models: User, Session

app.register(authPlugin, {
  strategy: 'sessions',
  auth,
  models: { User, Session },
  secureCookie: true, // optional; defaults to true when NODE_ENV=production
});

Routes:

| Method | Path | Auth required | Description | |----------|-------------------|:-------------:|--------------------------------------------------| | POST | /signup | | Create a user account | | POST | /login | | Authenticate; receive access + refresh tokens | | GET | /profile | ✓ | Return the current user | | POST | /logout | ✓ | Delete the current session | | POST | /auth/refresh | | Exchange a refresh token for a new access token | | GET | /sessions | ✓ | List all sessions for the current user | | DELETE | /sessions | ✓ | Delete all sessions except the active one | | DELETE | /sessions/:id | ✓ | Delete a specific session |

Example: signup, login (API client), call a protected route

curl -X POST http://localhost:3000/signup \
  -H "Content-Type: application/json" \
  -d '{"username": "alice", "email": "[email protected]", "password": "correct horse battery staple"}'
# 201
# { "id": 1, "username": "alice", "email": "[email protected]" }

curl -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"identifier": "alice", "password": "correct horse battery staple"}'
# 201
# {
#   "access_token": "...",
#   "refresh_token": "...",
#   "access_token_expires_at": "2026-08-31T12:15:00.000Z",
#   "refresh_token_expires_at": "2026-09-07T12:00:00.000Z"
# }

curl http://localhost:3000/profile \
  -H "Authorization: Bearer <access_token>"
# 200
# { "id": 1, "username": "alice", "email": "[email protected]" }

A web client (x-client-type: web header, or Accept: text/html) gets the same tokens set as HttpOnly cookies instead of in the body - /login then just returns the plain-text message "Authenticated successfully".

POST /auth/refresh takes { "refresh_token": "..." } for API clients (or reads the refresh_token cookie for web clients) and returns a new access_token/refresh_token pair in the same shape as /login.

DELETE /sessions/:id returns 409 with { "error": "conflict", "message": "Cannot delete the active session. Use the /logout endpoint instead." } if you try to delete the session you're currently authenticated with - use /logout for that instead.


magic-links

Passwordless email login. The plugin creates a magic-link record and fires your hook — you are responsible for sending the email.

Required models: User, Session, MagicLink
Required hook: onMagicLinkCreated

app.register(authPlugin, {
  strategy: 'magic-links',
  auth,
  models: { User, Session, MagicLink },
  hooks: {
    onMagicLinkCreated: async ({ user, token, code, tokenExpiresAt }) => {
      // Send the magic-link email here, e.g. via a job queue
      await emailQueue.add({ to: user.email, token, code });
    },
  },
});

Routes:

| Method | Path | Description | |--------|-----------------------|------------------------------------------------------------------------------| | POST | /magic-links | Look up user by email, create magic-link record, call onMagicLinkCreated | | POST | /magic-links/verify | Verify token + code; return access + refresh tokens |

Example

curl -X POST http://localhost:3000/magic-links \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]"}'
# 201
# { "message": "Magic link created" }

curl -X POST http://localhost:3000/magic-links/verify \
  -H "Content-Type: application/json" \
  -d '{"token": "<token from onMagicLinkCreated>", "code": "<code from onMagicLinkCreated>"}'
# 201
# {
#   "access_token": "...",
#   "refresh_token": "...",
#   "access_token_expires_at": "2026-08-31T12:15:00.000Z",
#   "refresh_token_expires_at": "2026-09-07T12:00:00.000Z"
# }

The token/code pair only exists in your onMagicLinkCreated hook (e.g. embedded in the email you send) - there's no endpoint to look them up.


mfa-sms

Password login followed by an SMS one-time code. After the password check the plugin creates an SMS code record and fires your hook — you are responsible for sending the SMS.

Required models: User, Session, SmsCode
Required hook: onSmsCodeCreated

app.register(authPlugin, {
  strategy: 'mfa-sms',
  auth,
  models: { User, Session, SmsCode },
  hooks: {
    onSmsCodeCreated: async ({ user, token, code }) => {
      // Send the SMS here
      await smsQueue.add({ to: user.mobile_number, code });
    },
  },
});

Routes:

| Method | Path | Description | |--------|--------------------------|--------------------------------------------------------------------------| | POST | /sessions | Authenticate with password; create SMS code, call onSmsCodeCreated | | POST | /sessions/verify-code | Verify token + SMS code; return access + refresh tokens |

Example

curl -X POST http://localhost:3000/sessions \
  -H "Content-Type: application/json" \
  -d '{"identifier": "alice", "password": "correct horse battery staple"}'
# 201
# { "token": "...", "message": "Authentication successful. SMS code sent to verify authentication" }

curl -X POST http://localhost:3000/sessions/verify-code \
  -H "Content-Type: application/json" \
  -d '{"token": "<token from the previous step>", "code": "<code from onSmsCodeCreated>"}'
# 201
# {
#   "access_token": "...",
#   "refresh_token": "...",
#   "access_token_expires_at": "2026-08-31T12:15:00.000Z",
#   "refresh_token_expires_at": "2026-09-07T12:00:00.000Z"
# }

Don't register this alongside sessions. mfa-sms gates /sessions, not /login - it has no route conflict with the sessions strategy, so Fastify will happily register both. But sessions' /login has no idea mfa-sms exists: anyone with valid credentials can call /login instead of /sessions and get a full session with no SMS step at all, for any user, every time - mfa-sms has no per-user opt-in, so there's nothing that distinguishes an SMS-gated login from a bypassed one except which endpoint was called. If you want password login that's optionally (or always) gated behind an SMS code on the same /login route, use the CLI wizard's SMS MFA option instead - it generates a single /login that checks a per-user opt-in flag and gates itself, so there's no second, ungated path to the same account.


mfa-totp

Password login followed by a TOTP code (authenticator app, e.g. Google Authenticator or Authy). TOTP secrets are encrypted at rest using AES-256-GCM.

Required models: User, Session, MfaToken, RecoveryCode
Required option: totp

app.register(authPlugin, {
  strategy: 'mfa-totp',
  auth,
  models: { User, Session, MfaToken, RecoveryCode },
  totp: {
    serviceName: 'My App',           // displayed in the authenticator app
    secretEncryptionKey: process.env.TOTP_SECRET_ENCRYPTION_KEY,
    // Generate a key with:
    // node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
  },
});

Routes:

| Method | Path | Auth required | Description | |--------|-----------------------------------------|:-------------:|----------------------------------------------------------| | POST | /signup | | Create account; return session tokens immediately | | POST | /login | | Authenticate with password; return MFA token if MFA is enabled | | POST | /login/mfa | | Verify TOTP code or recovery code; return session tokens | | POST | /auth/mfa/recovery-codes | ✓ | Generate 10 one-time recovery codes | | POST | /auth/mfa/setup | ✓ | Generate TOTP secret + QR code image | | POST | /auth/mfa/verify | ✓ | Verify a TOTP code (confirm setup) | | POST | /auth/mfa/disable | ✓ | Disable MFA with password + TOTP code | | POST | /auth/mfa/disable-with-recovery-code | ✓ | Disable MFA with password + recovery code |

Example: enabling and using TOTP MFA

# 1. Sign up - MFA isn't enabled yet, so you get a session back immediately
curl -X POST http://localhost:3000/signup \
  -H "Content-Type: application/json" \
  -d '{"username": "alice", "email": "[email protected]", "password": "correct horse battery staple", "mobile_number": "+15551234567"}'
# 201 → { access_token, refresh_token, access_token_expires_at, refresh_token_expires_at }

# 2. Set up MFA (protected) - returns a QR code image for an authenticator app
curl -X POST http://localhost:3000/auth/mfa/setup \
  -H "Authorization: Bearer <access_token>"
# 200 → { "qrCodeImageData": "data:image/png;base64,..." }

# 3. Confirm setup with a code from the authenticator app
curl -X POST http://localhost:3000/auth/mfa/verify \
  -H "Authorization: Bearer <access_token>" -H "Content-Type: application/json" \
  -d '{"token": "123456"}'
# 200 → { "message": "TOTP token verified successfully" }

# 4. Generate recovery codes for account-recovery scenarios
curl -X POST http://localhost:3000/auth/mfa/recovery-codes \
  -H "Authorization: Bearer <access_token>"
# 201 → { "codes": ["ABCD-1234", "..."] }  (10 one-time codes)

# 5. On a later login, MFA is now required - you get an mfa token, not a session
curl -X POST http://localhost:3000/login \
  -H "Content-Type: application/json" \
  -d '{"identifier": "alice", "password": "correct horse battery staple"}'
# 201 → { "token": "<mfa_token>" }

# 6. Exchange the mfa token + a TOTP code (or a recovery code) for a session
curl -X POST http://localhost:3000/login/mfa \
  -H "Content-Type: application/json" \
  -d '{"token": "<mfa_token>", "code": "123456"}'
# 201 → { access_token, refresh_token, access_token_expires_at, refresh_token_expires_at }

To disable MFA, POST /auth/mfa/disable with { "password": "...", "code": "<totp code>" }, or POST /auth/mfa/disable-with-recovery-code with { "password": "...", "code": "<recovery code>" } - both protected routes, both return { "message": "MFA TOTP disabled successfully" }.


forgotten-password

Forgot-password and reset-password flow. The plugin fires your hook with the validated identifier — you are responsible for looking up the user, creating the reset record, and sending the email.

Required models: User, ForgotPassword
Required hook: onForgotPasswordRequested

app.register(authPlugin, {
  strategy: 'forgotten-password',
  auth,
  models: { User, ForgotPassword },
  hooks: {
    onForgotPasswordRequested: async ({ identifier, isEmail }) => {
      // Look up the user, create a ForgotPassword record, and send the email
      const user = isEmail
        ? await User.query().where({ email: identifier }).first()
        : await User.query().where({ username: identifier }).first();

      if (user) {
        const record = await createForgotPasswordRecord(user.id);
        await emailQueue.add({
          to: user.email,
          selector: record.selector,
          token: record.plainToken,
        });
      }
    },
  },
});

Routes:

| Method | Path | Description | |--------|-----------------------------|------------------------------------------------------------------------| | POST | /forgot-password | Validate identifier; call onForgotPasswordRequested | | GET | /reset-password/:selector | Validate selector + token from query string | | POST | /reset-password | Validate selector + token; update user password |

The POST /forgot-password route always returns the same neutral message regardless of whether the account exists, to prevent user enumeration.

Example

curl -X POST http://localhost:3000/forgot-password \
  -H "Content-Type: application/json" \
  -d '{"identifier": "[email protected]"}'
# 200
# { "message": "If an account with that username/email exists, we've sent password reset instructions." }

# selector/token come from the reset link your onForgotPasswordRequested hook sent
curl "http://localhost:3000/reset-password/<selector>?token=<token>"
# 200
# { "message": "Password reset token is valid" }

curl -X POST http://localhost:3000/reset-password \
  -H "Content-Type: application/json" \
  -d '{"selector": "<selector>", "token": "<token>", "password": "new password", "password_confirmation": "new password"}'
# 200
# { "message": "Password reset successfully" }

Protecting routes

The plugin exposes createAuthenticateSession — a factory for a Fastify preHandler that validates the session on protected routes in your own app.

import { createAuthenticateSession } from '@anephenix/fastify-auth/middleware/authenticate';

const authenticateSession = createAuthenticateSession({ Session });

app.get('/dashboard', { preHandler: [authenticateSession] }, async (request, reply) => {
  // request.user and request.access_token are populated
  reply.send({ user: request.user });
});

The middleware:

  1. Extracts the access token from Authorization: Bearer <token> or the access_token cookie.
  2. Looks up the session and checks it has not expired.
  3. Loads the related user via session.$relatedQuery('user').
  4. Attaches request.user and request.access_token for downstream handlers.
  5. Returns 401 at the first failing step.

Web vs API clients

The sessions strategy detects the client type from the incoming request:

  • Web clients — requests with x-client-type: web header or Accept: text/html — receive tokens as HttpOnly cookies (access_token and refresh_token). This is the safest option for browser apps.
  • API clients — all other requests — receive tokens in the JSON response body.

The secureCookie plugin option controls whether the Secure flag is set on cookies. It defaults to true when NODE_ENV === 'production' and false otherwise.

Building blocks

The five strategies above are themselves composed from a set of smaller, independently-tested functions exported from @anephenix/fastify-auth/core. They're documented here for anyone who wants to hand-assemble a custom combination that doesn't fit a single built-in strategy (e.g. password and magic-link login, with optional per-user TOTP layered on top of both) - which is exactly what the CLI wizard generates.

import {
  verifyPassword,
  RateLimitedError,
  createSession,
  respondWithNewSession,
  respondWithRefreshedSession,
  issueMfaChallenge,
  buildTotpCrypto,
  verifyTotpCode,
  verifyRecoveryCode,
  validateResetToken,
  createProfileHandler,
  createLogoutHandler,
  createRefreshHandler,
  createListSessionsHandler,
  createDeleteAllSessionsHandler,
  createDeleteSessionHandler,
} from '@anephenix/fastify-auth/core';

| Export | What it does | |--------|---------------| | verifyPassword(auth, User, identifier, password) | Validates identifier/password are present, looks the user up via User.findByIdentifier(), then performs a timing-safe password check (auth.verifyPasswordSafe) and login rate limiting (auth.checkRateLimit) itself - the shared first-factor check used by sessions, mfa-sms and mfa-totp (including its MFA-disable routes). Throws RateLimitedError (with a retryAfter in seconds) when the account is currently locked out; strategies catch this and respond 429 with a Retry-After header. | | RateLimitedError | Error class thrown by verifyPassword() when rate limited. Has a retryAfter: number property (seconds). | | createSession(Session, userId) | Creates a Session record and returns just the token fields (access_token, refresh_token, access_token_expires_at, refresh_token_expires_at). | | respondWithNewSession({ request, reply, auth, secureCookie, tokens }) | Sends a freshly-created session - HttpOnly cookies for web clients, JSON body for API clients (see Web vs API clients). | | respondWithRefreshedSession({ request, reply, auth, secureCookie, tokens }) | Same as above, but for a refreshed access token (only resets the access_token cookie, not refresh_token). | | issueMfaChallenge(MfaToken, auth, userId) | Generates and persists a short-lived MFA token, to hand back to the client instead of a session, when a user has MFA enabled. | | buildTotpCrypto(totpOptions) | Builds the AES-256-GCM encrypt/decrypt pair used to store TOTP secrets at rest. | | verifyTotpCode(totpCrypto, encryptedSecret, code) | Decrypts a stored TOTP secret and checks a code against it. | | verifyRecoveryCode(RecoveryCode, userId, code) | Verifies and consumes a one-time MFA recovery code. | | validateResetToken({ ForgotPassword, auth, selector, token }) | Looks up and validates a password-reset record (not found/expired/used/mismatched token all report the same generic error). | | createProfileHandler() / createLogoutHandler(Session) / createRefreshHandler({ Session, auth, secureCookie }) / createListSessionsHandler(Session) / createDeleteAllSessionsHandler(Session) / createDeleteSessionHandler(Session) | Route-handler factories for managing an existing session - /profile, /logout, /auth/refresh, listing and revoking sessions - independent of how that session was created (password, magic link, or MFA). |

Model interfaces

The plugin calls a fixed set of static and instance methods on each model. Your models can have additional fields; they just need to satisfy these contracts.

User

interface IUserModel {
  id: number | string;
  username?: string;
  email?: string;
  mobile_number?: string;     // required for mfa-sms
  mfa_totp_secret?: string | null;  // required for mfa-totp
  hashed_password?: string;   // required for sessions/mfa-sms/mfa-totp - read directly by verifyPassword()
  failed_login_attempts?: number;               // required for the same strategies - login rate limit bookkeeping
  failed_login_window_started_at?: string | Date | null;
  updatePassword?(password: string): Promise<void>; // required for forgotten-password
  $query(): QueryBuilder;
  $relatedQuery(relation: string): QueryBuilder;
}

interface IUserModelStatic {
  query(): QueryBuilder;
  // Looks up a user by username or email. verifyPassword() uses this to
  // perform the timing-safe password check and rate limiting itself, so
  // that logic doesn't need to be reimplemented per model.
  findByIdentifier(identifier: string): Promise<IUserModel | undefined | null>;
}

hashed_password, failed_login_attempts and failed_login_window_started_at need backing columns on your users table - failed_login_attempts should default to 0, failed_login_window_started_at is nullable. Tune the lockout threshold/window via new Auth({ loginOptions: { maxAttempts, windowSeconds } }).

Session

interface ISessionModel {
  id: number | string;
  user_id: number | string;
  access_token: string;
  refresh_token: string;
  access_token_expires_at: string;
  refresh_token_expires_at: string;
  user_agent?: string;
  ip_address?: string;
  accessTokenHasExpired(): boolean;
  refreshTokenHasExpired(): boolean;
  $query(): QueryBuilder;
}

interface ISessionModelStatic {
  query(): QueryBuilder;
  generateTokens(): {
    access_token: string;
    access_token_expires_at: string;
    refresh_token: string;
    refresh_token_expires_at: string;
  };
}

MagicLink

interface IMagicLinkModelStatic {
  query(): QueryBuilder;
  generateTokens(): Promise<{ token: string; tokenExpiresAt: Date; code: string; hashedCode: string }>;
  verifyTokenAndCode(token: string, code: string): Promise<{ userId: number | string }>;
}

SmsCode

interface ISmsCodeModel {
  codeHasExpired(): boolean;
  verifyCode(code: string): Promise<boolean>;
  $query(): QueryBuilder;
}

interface ISmsCodeModelStatic {
  query(): QueryBuilder;
}

MfaToken

interface IMfaTokenModel {
  number_of_attempts: number;
  used_at?: string;
  $query(): QueryBuilder;
}

interface IMfaTokenModelStatic {
  query(): QueryBuilder;
}

RecoveryCode

interface IRecoveryCodeModelStatic {
  query(): QueryBuilder;
  generateCodes(): Promise<string[]>;
  checkForRecoveryCodeAndConsume(userId: number | string, code: string): Promise<boolean>;
}

ForgotPassword

interface IForgotPasswordModel {
  selector: string;
  token_hash: string;
  expires_at: Date;
  used_at?: Date | string | null;
  markAsUsed(): Promise<void>;
  $query(): QueryBuilder;
}

interface IForgotPasswordModelStatic {
  query(): QueryBuilder;
}

TypeScript

All types are re-exported from the package root:

import type {
  AuthFastifyPluginOptions,
  AuthFastifyModels,
  AuthFastifyHooks,
  TotpOptions,
  Strategy,
  IUserModel,
  IUserModelStatic,
  ISessionModel,
  ISessionModelStatic,
  IMagicLinkModel,
  IMagicLinkModelStatic,
  ISmsCodeModel,
  ISmsCodeModelStatic,
  IMfaTokenModel,
  IMfaTokenModelStatic,
  IRecoveryCodeModel,
  IRecoveryCodeModelStatic,
  IForgotPasswordModel,
  IForgotPasswordModelStatic,
  MagicLinkCreatedParams,
  SmsCodeCreatedParams,
  ForgotPasswordRequestedParams,
} from '@anephenix/fastify-auth';

CLI wizard

The five strategies cover common cases, but some apps need a combination that doesn't fit any single one - e.g. password and magic-link login, both optionally gated by the same per-user TOTP MFA, which the built-in strategies can't do together (mfa-totp owns /signup//login itself and can't be registered alongside sessions; magic-links has no MFA awareness at all). For that, generate a custom combined setup instead:

npx @anephenix/fastify-auth wizard

This walks through a few questions - password login, magic-link login, an optional MFA method on top (None / TOTP / SMS - a generated app supports at most one second-factor mechanism, opt-in per user either way), and forgotten-password (only asked if password login is selected) - then generates a working combination under src/ (--output <dir> to change that, --force to overwrite existing generated files):

src/lib/auth.ts          # the shared Auth instance (+ TotpCrypto if TOTP was selected)
src/models/User.ts       # + Session.ts, and MagicLink.ts / ForgotPassword.ts for whichever
                          #   features were selected, plus either MfaToken.ts + RecoveryCode.ts
                          #   (TOTP) or SmsCode.ts (SMS), depending on the MFA choice
src/routes/auth.ts       # the composed routes
src/index.ts             # only created if one doesn't already exist

Unlike the model interfaces above (which you implement yourself), these are real, working Objection.js model files generated for you, and routes/auth.ts is built on the same building blocks the five strategies use internally - verifyPassword, createSession, issueMfaChallenge, verifyTotpCode, validateResetToken, the session- management handlers, and so on - rather than being copied inline, so security-relevant logic stays in one place and picks up fixes when you upgrade the package. The generated code is yours to edit freely from there.

Selecting both magic-link and an MFA method produces something the built-in strategies don't: /magic-links/verify checks the user's MFA status and issues a challenge instead of a session when it's enabled, so a magic link can't be used to bypass MFA the way it could with the magic-links strategy alone. SMS MFA generated this way is also opt-in per user (via /auth/mfa/sms/setup) - unlike the standalone mfa-sms strategy, which requires it for every password login with no per-user toggle.

If password login is selected, your users table needs hashed_password, failed_login_attempts (integer, default 0) and failed_login_window_started_at (nullable timestamp) columns - the latter two back the login rate limiting configured in lib/auth.ts. See the TODO comment at the top of the generated models/User.ts.

If TOTP is selected, install its two dependencies and set an encryption key:

npm i otplib qrcode
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# set the output as TOTP_SECRET_ENCRYPTION_KEY in your environment

If SMS is selected, wire up your SMS provider where the generated /login/mfa and /auth/mfa/sms/setup code currently just console.log()s the code instead of sending it.

FAQs

Can I support both password login and magic-link sign-in in the same app?

Yes - register the plugin twice on the same Fastify instance, once per strategy:

app.register(authPlugin, {
  strategy: 'sessions',
  auth,
  models: { User, Session },
});

app.register(authPlugin, {
  strategy: 'magic-links',
  auth,
  models: { User, Session, MagicLink },
  hooks: {
    onMagicLinkCreated: async ({ user, token, code }) => {
      await emailQueue.add({ to: user.email, token, code });
    },
  },
});

This works because the plugin is registered via fastify-plugin, so each registration adds its routes straight onto your app instance rather than into its own isolated context. The route sets don't collide - sessions owns /signup, /login, /profile, /logout, /auth/refresh and /sessions*; magic-links only adds /magic-links and /magic-links/verify. Both strategies also create Session rows the same way, so a session created via a magic link is indistinguishable from one created via password - /profile, /logout and session listing/revocation from the sessions strategy work for magic-link users too, with no extra wiring.

The one gap to know about: magic-links has no signup route of its own. POST /magic-links looks up the user by email and errors with "User not found for email" if there's no match - it doesn't create accounts. A brand-new user needs to go through the sessions strategy's POST /signup first (which currently requires a password), and can then log in either way afterwards. For a true "sign up via magic link, no password ever set" flow, you'd need your own small custom signup route rather than relying on the bundled one.

License

MIT