@trieb.work/payload-auth-pwless
v0.0.0
Published
Payload CMS plugin for passwordless authentication: WebAuthn/passkeys, magic links, OAuth, and refresh-token session handling
Readme
@trieb.work/payload-auth
A self-hosted, passwordless authentication plugin for Payload CMS 3.x.
Replaces Payload's built-in email+password strategy with WebAuthn/passkeys, magic-link email login, and OAuth (Google, Facebook) — backed by a custom refresh-token session system. No third-party auth service required.
Features
- WebAuthn / passkeys — registration and authentication, multi-credential per user, multi-host support with anti-spoofing origin checks.
- Magic link email login — passwordless, rate-limited, anti-enumeration by design.
- OAuth — Google and Facebook out of the box, verified-email-only, automatic account linking by email.
- Sessions & refresh tokens — short-lived access tokens (JWT) + long-lived
refresh tokens in
HttpOnlycookies, device-fingerprint binding, request deduplication, scheduled cleanup of expired sessions via Payload's Jobs Queue. - Application contexts (optional) — run one Payload instance behind multiple frontends (e.g. a public site and a back office) with different session lifetimes, login paths, and email branding per context.
- Rate limiting on sensitive endpoints (in-memory — see Security notes).
- Last-login tracking (timestamp + IP).
- Optional onboarding step (
firstName/lastName/onboardingComplete) for profile completion after first login. - Dev-only agent/browser auto-login for local testing and CI, gated behind an explicit opt-in, a shared secret, and a localhost allowlist.
Roles and permissions are intentionally not part of this plugin — bring your own authorization layer on top.
Installation
pnpm add @trieb.work/payload-auth
# or: npm install / yarn addPeer dependencies: payload ^3.0.0, next ^15.0.0 || ^16.0.0, react ^19.0.0.
Quick start
// payload.config.ts
import { authPlugin } from '@trieb.work/payload-auth'
import { buildConfig } from 'payload'
export default buildConfig({
collections: [
{
slug: 'users',
auth: true, // required — the plugin extends this collection
fields: [],
},
// ...
],
plugins: [
authPlugin({
serverURL: process.env.NEXT_PUBLIC_SERVER_URL,
rpName: 'My App',
}),
],
secret: process.env.PAYLOAD_SECRET,
})Breaking behavioral change: the plugin sets
disableLocalStrategy: trueon theuserscollection and registers its own auth strategy. Existing email+password logins on that collection stop working once the plugin is added — plan a migration path (e.g. magic link to the same email) if you have existing password-based users.
The users collection must have auth: true set already; the plugin only
extends it.
Configuration
All options are optional; sensible generic defaults are shipped. Full type:
PayloadAuthPluginConfig (exported from the package).
| Option | Default | Description |
| ---------------------------------------------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| serverURL | NEXT_PUBLIC_SERVER_URL env var, then http://localhost:3000 | Base URL used for OAuth callbacks and magic-link URLs. |
| rpName | 'Payload' | WebAuthn Relying Party name shown to users during passkey registration. |
| rpID | hostname of serverURL | WebAuthn Relying Party ID. |
| allowedOrigins | [serverURL] | Origins allowed to perform WebAuthn ceremonies (needed for multi-host/subdomain setups). |
| usersCollectionSlug | 'users' | Slug of the existing collection the plugin extends. |
| sessionsCollectionSlug | 'sessions' | Slug of the sessions collection the plugin creates. |
| webauthnCollectionSlug | 'webauthn-credentials' | Slug of the WebAuthn credentials collection the plugin creates. |
| enableWebAuthn / enableMagicLink / enableOAuth | true | Toggle each auth method independently. |
| enableOnboarding | true | Registers /api/auth/onboarding plus firstName/lastName/onboardingComplete fields. |
| enableAgentLogin | false | Registers the dev-only auto-login endpoint. See Agent login. |
| contexts | {} | Optional map of named application contexts — see Application contexts. |
| defaultContext | first key of contexts | Context assigned to auto-created users when none is requested. |
| magicLink | — | allowUser guard, autoCreateUsers toggle, getLoginPath override. See MagicLinkConfig. |
| email | — | Global magic-link email config (from, subject, buildEmailHTML). Overridable per context. |
| oauth.providers.{google,facebook} | env vars | { clientId, clientSecret }, see OAuth setup. |
| webauthn.canManageUser | denies all | Lets privileged users view/delete another user's passkeys. |
| tokens.accessTokenLifetime | 900 (15 min) | Access token (JWT) lifetime in seconds. |
| tokens.defaultSessionLifetime | 604800 (7 days) | Refresh-token/session lifetime in seconds when no per-context value applies. |
| tokens.magicLinkValidity | 900 (15 min) | Magic-link token validity in seconds. |
| sessionCleanup | daily at 2 AM | Cron schedule for the expired-session cleanup job (enabled, cron, queue). |
| agentLogin | — | email, secret, allowedHostnames for the dev-only auto-login endpoint. |
Application contexts
Contexts are entirely optional. Configure them when a single Payload instance serves multiple frontends that need different session lifetimes, login paths, or email branding:
authPlugin({
contexts: {
app: { loginPath: '/login', sessionLifetime: 30 * 24 * 60 * 60 },
backoffice: {
loginPath: '/admin/login',
sessionLifetime: 8 * 60 * 60,
email: { subject: 'Sign in to the back office' },
},
},
defaultContext: 'app',
})When configured, an applicationContext select field is added to the users
collection, and clients pass ?context=<name> to the magic-link and OAuth
endpoints to scope the session.
OAuth setup
Enable a provider by supplying credentials, either via plugin options or environment variables:
| Provider | Env vars | Redirect URI to register with the provider |
| -------- | ---------------------------------------------------------- | ---------------------------------------------- |
| Google | GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET | {serverURL}/api/auth/oauth/google/callback |
| Facebook | FACEBOOK_OAUTH_CLIENT_ID, FACEBOOK_OAUTH_CLIENT_SECRET | {serverURL}/api/auth/oauth/facebook/callback |
Only verified-email OAuth profiles are accepted. New users are created automatically on first login; existing users are matched first by linked social account, then by email (which links the social account to that user going forward).
WebAuthn / passkey gotchas
rpIDmust equal (or be a registrable-domain suffix of) the origin the browser is actually on. Browsers treatlocalhostas a public suffix, so a staticrpID: 'localhost'will fail on anapp.localhostsubdomain with a "RP ID wrong" error.- If you serve the app from more than one host (subdomains, staging vs.
production), list every allowed origin in
allowedOrigins. Requests from a non-allowlistedHost/x-forwarded-hostfall back to the staticserverURLrather than trusting the header — this prevents a forged Host header from binding credentials to an attacker-controlled domain.
Magic link
magicLink.autoCreateUsers(defaulttrue) registers a new user on first magic-link request (passwordless sign-up). Set tofalsefor invite-only apps.magicLink.allowUseris a guard executed before a link is issued — returnfalseto deny (e.g. restrict a context to an email domain or an existing-user check). It receives{ context, email, payload, req, user }.- Requires an email adapter
configured on the root Payload config, plus an
EMAIL_FROM_ADDRESSenv var oremail.fromoption. - Both endpoints always return
{ success: true }on the request step regardless of whether the email exists, to prevent account enumeration.
Agent login (dev-only)
enableAgentLogin: true registers
GET /api/auth/agent-login?secret=<secret>&returnUrl=/, useful for automated
browser testing (e.g. Playwright, Claude-in-Chrome) without going through a real
auth flow. It:
- only responds to allowlisted hostnames (
localhost,127.0.0.1,::1by default), - requires a shared secret (
agentLogin.secretoption orAGENT_AUTO_LOGIN_SECRETenv var), - creates or reuses a single agent user (
[email protected]by default).
Do not enable this in a production deployment.
Endpoints registered
| Method | Path | Requires |
| -------- | ----------------------------------------- | --------------------------------- |
| POST | /api/auth/magic-link/request | — |
| POST | /api/auth/magic-link/verify | — |
| GET | /api/auth/oauth/:provider | enableOAuth |
| GET | /api/auth/oauth/:provider/callback | enableOAuth |
| POST | /api/auth/webauthn/register-options | authenticated, enableWebAuthn |
| POST | /api/auth/webauthn/register-verify | authenticated, enableWebAuthn |
| POST | /api/auth/webauthn/has-credentials | authenticated, enableWebAuthn |
| GET | /api/auth/webauthn/credentials | authenticated, enableWebAuthn |
| DELETE | /api/auth/webauthn/credentials/:id | authenticated, enableWebAuthn |
| POST | /api/auth/webauthn/authenticate-options | enableWebAuthn |
| POST | /api/auth/webauthn/authenticate-verify | enableWebAuthn |
| POST | /api/auth/refresh | valid refresh-token cookie |
| POST | /api/auth/logout | — |
| POST | /api/auth/logout-all | authenticated |
| POST | /api/auth/onboarding | authenticated, enableOnboarding |
| GET | /api/auth/agent-login | enableAgentLogin (dev only) |
Security notes
- Rate limiting is in-process (an in-memory
Map), applied to the magic-link and refresh endpoints. This works correctly on a single instance but does not share state across replicas/serverless invocations — for multi-instance deployments, put a shared store (e.g. Redis) in front of these endpoints or accept best-effort protection. - Device fingerprint is User-Agent-only, deliberately excluding client IP. IP addresses legitimately change mid-session (dual-stack IPv4/IPv6, Wi-Fi ↔ mobile handoff, CGNAT, rotating egress IPs), which would otherwise cause false "device mismatch" logouts. The high-entropy refresh token is the actual security boundary; the fingerprint only catches a token replayed from a clearly different client.
- OAuth
stateis a signed, short-lived JWT (10 minutes), and thereturnUrlcarried through both the OAuth flow and the dev-only agent-login endpoint is validated to be a same-origin, path-relative URL before being used in a redirect — this closes an open-redirect vector. - Cookies are
HttpOnly,SameSite=Lax, andSecurewhenNODE_ENV=production. disableLocalStrategy: truefully replaces Payload's built-in email+password strategy on the extended collection — see the Quick start warning above.
Development
pnpm install
pnpm dev # runs the dev/ Payload+Next app against an in-memory MongoDB
pnpm test:int # vitest (unit + integration)
pnpm test:e2e # Playwright, against the dev app
pnpm build # builds dist/
pnpm typecheck
pnpm lintThe dev/ app requires no external database by default — it spins up an
in-memory MongoDB replica set automatically. Set DATABASE_URL/MONGODB_URI to
point at a real MongoDB instance instead.
License
MIT
