You're reading docs for Nextly Alpha. APIs may change between releases.
Plugins
Auth extensibility
Extend authentication from a plugin — custom strategies, external sign-in (OAuth/SSO), auth-flow hooks, and first-class multi-step challenges (2FA) — without forking core.
Alpha (0.x) — @experimental. The auth-extensibility surface (D71/D57) is
new and ships @experimental: it works and is integration-tested, but stays
experimental until a first-party plugin exercises it in production (D55). Pin
your versions.
Nextly's auth is pluggable through four surfaces, modeled on what Payload, Strapi,
and WordPress allow — and adding first-class multi-step (2FA) that those do
piecemeal:
Surface
What it does
Who provides it
Strategies
Decide who a user is from a credential the request carries (password, an API token, a magic-link code)
The built-in email/password login is itself just the password strategy, always
present and run last; it cannot be disabled. A sign-in that starts with a
redirect — OAuth, SSO — is not a strategy: see
signing in with an external provider.
A strategy authenticates users, so it's the highest-trust extension point. A plugin
may ship a strategy, but it only takes effect when the app enables it:
// nextly.config.tsimport { defineConfig } from "nextly/config";import { googleOneTap } from "@acme/nextly-plugin-google";export default defineConfig({ // One Tap posts a Google ID token with the request, so it is a strategy. auth: { strategies: [googleOneTap({ clientId: process.env.GOOGLE_CLIENT_ID! })] },});
Hooks, challenges, and UI follow normal contribution rules (active as soon as the
plugin is installed). Strategies don't — that's deliberate (secure-by-default).
A provider whose sign-in starts with a redirect does not fit the strategy chain,
which runs against a request that already carries a credential. Such a plugin
mounts its own routes and finishes the login with ctx.auth.completeLogin.
import { definePlugin } from "@nextlyhq/plugin-sdk";export const googleAuth = definePlugin({ name: "@acme/google-auth", version: "1.0.0", nextly: ">=0.0.2-alpha.63", // Finishing a login signs in whichever account the plugin names, so it is // declared where an operator reviews the plugin. capabilities: { auth: { login: true } }, contributes: { routes: [ { method: "GET", path: "/google/authorize", public: true, handler: (_req, ctx) => redirectToGoogle(ctx), }, { method: "GET", path: "/google/callback", public: true, async handler(request, ctx) { // Proving who this is stays the plugin's job; see the duties below. // This checks the `state` cookie, sends the PKCE verifier, and // validates the ID token's `iss`, `aud` and `nonce`. const profile = await exchangeCodeForProfile(request, ctx); // Keyed on the provider's stable subject, never on an email alone, // and never on anything the request supplies. const user = await findLocalUserFor(profile, ctx); if (!user) { // A relative redirect: `Response.redirect` needs an absolute URL // and throws on this one. return new Response(null, { status: 302, headers: { Location: "/admin/login?error=signin-failed" }, }); } // Core finishes the login: the account-state gate, the hooks, the // audit row, the cookies, and a second factor if one is configured. return ctx.auth.completeLogin(user.id, { request, // Begins with the plugin's slug, so the audit trail says which // plugin signed this person in. strategy: "acme-google-auth:google", next: "/admin", }); }, }, ], auth: { ui: { providers: [ { strategy: "acme-google-auth:google", label: "Continue with Google", // Where the button goes. A provider with neither an href nor a // component, or with an href that is not a same-origin path, is // dropped from the login page and reported at boot. // Routes are served under the raw package name. href: "/admin/api/plugins/@acme/google-auth/google/authorize", }, ], }, }, },});declare function redirectToGoogle(ctx: unknown): Response;declare function exchangeCodeForProfile( request: Request, ctx: unknown): Promise<{ sub: string; email: string; emailVerified: boolean }>;declare function findLocalUserFor( profile: { sub: string; email: string; emailVerified: boolean }, ctx: unknown): Promise<{ id: string } | null>;
What stays the plugin's job. Core cannot see the provider exchange, so it
cannot check any of it. Each of these is what stops a login CSRF or an account
takeover (RFC 9700):
Bind the state parameter to an HttpOnly, single-use cookie set by the
authorize route, and refuse a callback whose state does not match it.
Use PKCE with S256.
Validate the ID token: its signature, iss, aud, expiry, and the nonce
you sent.
Key the linked account on the provider's sub, which is stable, rather than
on an email address, which can change hands.
Match an existing account by email only when the provider says the address
is verified (email_verified).
Never take a user id from the request: pass completeLogin only an id your
own lookup produced.
Clear the transaction cookie on the response, on success and on failure.
What completeLogin does for you, in this order: loads the user by id, so a
caller cannot fabricate profile fields; checks that the account may hold a
session at all; runs the beforeLogin hooks, so a maintenance mode or an IP
allowlist applies to an external login too; runs the afterAuthenticate hooks,
which is where a second factor interrupts; and only then mints the session.
The account check comes before the hooks because a hook may act, sending a code
or writing a record, and must never do so for an account that cannot sign in.
Such an account reaches no hook; its attempt is still recorded in the
login-failed audit row.
The password-attempt lockout does NOT apply here, and a plugin cannot record its
login as the password strategy to switch it on: the strategy must start with
the plugin's own slug. An external login is not a password attempt, and
otherwise anyone who knows an address could lock its owner out of their
provider with five wrong guesses.
It answers every login outcome with a redirect, so every plugin fails the same
safe way: a refusal lands on /admin/login?error=signin-failed with an audit
row that names no account, and a second factor, or an administrator-set
password that must be replaced first, lands on /admin/login?resume=1 with
the pending token in an HttpOnly cookie rather than in the URL. A hook that
blocks an external login throws a typed 4xx NextlyError (for example
NextlyError.forbidden) to be answered that way.
What is not a login outcome is thrown to the calling route instead: an untyped
or 5xx error, an afterAuthenticate hook returning another account, and a
plugin calling completeLogin without capabilities.auth.login or with a
strategy name that does not begin with its own slug.
Strategies remain app-level: they live in defineConfig({ auth: { strategies } })
rather than in a plugin. A plugin that finishes logins with completeLogin
does add a way in, which is why it must declare capabilities.auth.login —
nextly plugins info shows it, and without it completeLogin refuses — and
why its strategy names must begin with its own slug (acme-google-auth:), so
every audit row names the plugin that signed someone in.
2FA is an afterAuthenticate hook that returns a challenge instead of letting
the session issue, plus a challenge definition that resolves the code. Core
handles the hard part — a single-purpose, short-lived pending token, the
attempt cap, and re-issuing the session only after the challenge resolves.
import { definePlugin } from "@nextlyhq/plugin-sdk";export const twoFactor = definePlugin({ name: "@acme/nextly-plugin-2fa", version: "0.1.0", nextly: ">=0.0.2-alpha.63", contributes: { auth: { // 1. After the password (or any strategy) authenticates, require a second // step IF the user has 2FA enabled. Returning `{ challenge }` pauses login. hooks: { afterAuthenticate: async (user, ctx) => { const enabled = await isTotpEnabled(ctx, user.id); if (!enabled) return user; // continue → session issues return { challenge: { id: "totp", userId: user.id } }; }, }, // 2. Resolve the challenge: validate the TOTP code the client submits. challenges: [ { id: "totp", resolve: async ({ userId, response }, ctx) => { const ok = await verifyTotp(ctx, userId, String(response.code ?? "")); return ok ? { ok: true } : { ok: false, reason: "bad-code" }; }, }, ], // 3. Render the step UI when the login response is a `totp` challenge. ui: { challengeViews: { totp: "@acme/nextly-plugin-2fa/admin#TotpPrompt" } }, }, },});
uiHint is the challenge's uiHint, or null when it sets none. A login
finished by ctx.auth.completeLogin instead redirects to
/admin/login?resume=1 with the pending token in an HttpOnly cookie, as it
does for an administrator-set password that must be replaced first.
The login page renders the challengeViews["totp"] component with
ChallengeViewProps from @nextlyhq/plugin-sdk/admin. The view collects the
code and calls resolve({ code }); the host posts it:
with the pending token from the body, or from the cookie when the login
resumed. pendingToken in the view's props is deprecated: a resumed login
has none to give it. A view that still posts its own answer with it keeps
working, and only for such a view does onResolved decide where the login
lands.
Core verifies the pending token (single-purpose, TTL'd, attempt-capped), runs your
challenge's resolve, and on success issues the real session — identical to a
direct login — and the host navigates to the login's destination. resolve
answers { ok, error?, continues?, next? }: continues means another step
follows (a forced password change), so the view must not call onResolved.
Calling it after the host has navigated changes nothing. A wrong code
re-issues a pending token until the cap is hit; the last refusal ends the
flow and the page says so.
The pending token authorizes nothing except resolving the challenge — it can never
establish a session (the access guard rejects it). One challenge pauses a
login at a time: the first an afterAuthenticate hook returns is the one asked,
and once it is answered only core's forced password change can follow.
All hooks are optional on contributes.auth.hooks. Any of them can abort by
throwing — a NextlyError keeps its own code, anything else becomes a generic
error. beforeRegister, afterAuthenticate, determineUser and
customizeClaims can also modify by returning a value; the others observe,
and what they return is ignored. afterAuthenticate can challenge.
Hook
When
Use
beforeLogin(input)
Before any strategy; in completeLogin, after the account check
Pre-checks; throw to block (maintenance mode, IP allowlist)
afterAuthenticate(user)
After a strategy identifies the user
Insert 2FA / step-up; decorate the user (the id must stay the same, or the login fails)
afterLogin(user)
After the session is minted, before the success audit row and the response
Side effects (last-login, notify); a throw fails the login
beforeRegister(data)
Before a new user is created
Normalize / augment the payload
afterRegister(user)
After a successful register
Welcome email, provisioning, CRM sync
beforeLogout / afterLogout
Around logout
Cleanup, revoke external sessions. beforeLogout receives no user
determineUser(request)
GET /auth/session only
Custom credential (API key, alt token) → return a user or null. The returned user must still pass the account gate: an unknown, deactivated, or (where sign-in requires it) unverified account is not answered as a session
customizeClaims(claims, user)
Building the JWT (login and refresh)
Add JWT claims only. Every claim core built — identity, roleIds and the token claims — is restored as core built it. A claim named after a configured user field can be neither added nor changed, and one whose value could not be read is left out rather than set to null. The reserved nbf, aud, iss and typ cannot be added
Hooks observe-or-mutate; they do not replace strategies. Use a strategy to
authenticate, a hook to gate/decorate.
Session/token issuance, cookies, CSRF, rate-limiting, and account lockout stay in core
— they're security-critical and identical for every auth method. You extend who you
are and the flow, not the session machinery. This matches where Payload, Strapi, and
WordPress draw the line.
Strategies are app-opt-in; a plugin's own login is not. A strategy
authenticates no one until the app lists it, but any installed plugin that
declares capabilities.auth.login can finish a login with
ctx.auth.completeLogin for whichever account it names. Check that
declaration with nextly plugins info before installing.
An enabled strategy/hook runs with full trust (D34, no sandbox in v1) — vet auth
plugins like any dependency.
Multi-step uses a single-purpose pending token (short TTL, attempt-capped) that
can't be used as a session.
The whole surface is @experimental until a first-party plugin graduates it (D55).