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:

SurfaceWhat it doesWho provides it
StrategiesDecide who a user is from a credential the request carries (password, an API token, a magic-link code)App opt-in (defineConfig({ auth: { strategies } }))
Auth-flow hooksModify / abort / challenge the login, register, logout, session flowPlugin contributes.auth.hooks
ChallengesA pending second step (2FA) that pauses login until resolvedPlugin contributes.auth.challenges
Auth-page UIProvider buttons, challenge views, login-form slotsPlugin contributes.auth.ui

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.

Strategies are opt-in (the trust boundary)

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.ts
import { 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).

Worked example: signing in with an external provider

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.

Worked example: TOTP two-factor (a challenge)

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" } },
    },
  },
});

The challenge flow on the wire

  1. POST /auth/login with valid credentials → the afterAuthenticate hook returns a challenge, so the response is not a session but:
    { "status": "challenge", "challengeType": "totp", "pendingToken": "…", "uiHint": null }
    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.
  2. 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:
    POST /auth/challenge/resolve   { "pendingToken": "…", "response": { "code": "123456" } }
    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.
  3. 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.

Auth-flow hooks reference

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.

HookWhenUse
beforeLogin(input)Before any strategy; in completeLogin, after the account checkPre-checks; throw to block (maintenance mode, IP allowlist)
afterAuthenticate(user)After a strategy identifies the userInsert 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 responseSide effects (last-login, notify); a throw fails the login
beforeRegister(data)Before a new user is createdNormalize / augment the payload
afterRegister(user)After a successful registerWelcome email, provisioning, CRM sync
beforeLogout / afterLogoutAround logoutCleanup, revoke external sessions. beforeLogout receives no user
determineUser(request)GET /auth/session onlyCustom 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.

What stays core (and why)

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.

Security notes

  • 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).

See also: Permissions · Data access · API reference.