You're reading docs for Nextly Alpha. APIs may change between releases.

Guides

Authentication

Custom email + password authentication with JWT sessions, API keys, RBAC, and the super-admin bootstrap flow.

Parts of this page need the release after 0.0.2-alpha.66: the access token's account check every five seconds (on 0.0.2-alpha.66 the token is trusted until it expires), the refresh cookie's scope, the last-super-admin rule, the rate limit on the email-verification routes, and /auth/pending.

Nextly ships a complete custom authentication system. It is not built on NextAuth, Auth.js, or any OAuth library. The runtime owns:

  • Email + password sign-up and login (bcrypt hashing, password-strength validation, email verification).
  • Stateless JWT access tokens (jose, HS256) plus opaque refresh tokens stored hashed in the database.
  • API key authentication (Authorization: Bearer nx_live_...) with read-only / full-access / role-based scopes.
  • Role-Based Access Control (RBAC) with code-defined and database-managed permissions.
  • A super-admin bootstrap flow at /admin/setup that creates the first user.

There is no OAuth provider integration in the runtime. Authentication is local to your database.

How sessions work

Sessions use a two-token architecture:

  • Access token -- JWT signed with HS256 (jose), 15-minute TTL. Its signature is verified, and its account is checked to still be allowed to use it, read from the database at most once every five seconds per account (see When an account may hold a session). Sent as the HTTP-only nextly_session cookie.
  • Refresh token -- opaque random string, 7-day TTL, SHA-256 hashed in the database for revocation support. Sent as the HTTP-only nextly_refresh cookie, scoped to /admin/api/auth so that both refresh and sign-out receive it.

When the access token expires, the admin UI silently refreshes it using the refresh token. Users only see a re-login prompt once the refresh token itself expires (7 days of inactivity by default). Every refresh consumes the old refresh token and issues a new one. A token whose row is gone is refused and logged as refresh-token-not-found (see Refresh-token rotation).

Cookies are scoped to /admin/* so Nextly auth never collides with frontend application cookies.

Reading the session in code

Reading a session checks its account in the database, so Nextly must be initialised in the process first. Nextly's own routes start it themselves. In your own route, call await getNextly({ config }) before getSession or requireAuth, or initialise Nextly at startup in instrumentation.ts with createRegister(config). A session read in a process where Nextly has not been initialised fails with an internal error, and the server log names this fix, rather than answering without the check.

// app/api/my-route/route.ts
import { getNextly } from "nextly";
import { getSession } from "nextly/auth";

import config from "../../../nextly.config";

export async function GET(req: Request) {
  await getNextly({ config });
  const result = await getSession(req, process.env.NEXTLY_SECRET ?? "");

  if (!result.authenticated) {
    return Response.json({ error: "Not authenticated" }, { status: 401 });
  }

  // result.user is the SessionUser
  return Response.json({
    userId: result.user.id,
    email: result.user.email,
    roleIds: result.user.roleIds,
  });
}

The SessionUser shape:

FieldTypeDescription
idstringUser's unique ID
emailstringUser's email address
namestringDisplay name
imagestring?Profile image URL
roleIdsstring[]Array of role IDs from the JWT claims

Custom user fields defined via defineConfig({ users: { fields } }) are also surfaced on the session user object.

Requiring authentication

import { getNextly } from "nextly";
import { requireAuth } from "nextly/auth";

import config from "../../../nextly.config";

export async function GET(req: Request) {
  await getNextly({ config });
  // Throws AuthenticationError if no valid session.
  const user = await requireAuth(req, process.env.NEXTLY_SECRET ?? "");
  return Response.json({ email: user.email });
}

When an account may hold a session

Needs the release after 0.0.2-alpha.66.

Every path that ends in a session asks the same question first, whether it is a password login, a second factor being answered, a forced password change, a token refresh, or a plugin finishing an external sign-in. An account is refused when it is deactivated, when its address is unverified and verification is required, or when it is locked by failed password attempts.

Each refusal answers identically, so the response cannot be used to tell a locked account from one that does not exist. Which of the three it was is recorded only for the operator: in the server log, and as metadata.reason on the login-failed audit row, which names no account.

The exception is an unverified address when the password was correct: the login answers EMAIL_NOT_VERIFIED, so the login page can offer to resend the verification link. Only someone who already holds the password sees it; a wrong password on the same account answers like any other refusal, and a locked account, or one an administrator deactivated, answers generically even to the right password. Asking for the link again does nothing for an address that is already verified.

The lockout is the exception to "every path": it applies to password attempts only. A refresh is not a password attempt, and neither is an external sign-in — otherwise anyone who knew an address could lock its owner out of their provider by typing five wrong passwords.

Deactivating an account, or an administrator setting its password, ends the sessions it already holds within ten seconds. Every access token issued to it before the change is refused wherever a session is read: getSession and requireAuth, GET /auth/session, a plugin's ctx.auth.currentUser, and the admin API. That includes a token from the Direct API's nextly.login(), which is otherwise valid for 30 days. The account's refresh tokens are deleted in the same change, so its next refresh is refused with REFRESH_FAILED. That answer clears no cookies: a missing refresh row is answered the same way as one another tab has just rotated, whose fresh cookies must survive. A client treats REFRESH_FAILED as signed out and sends the person to sign in again, as the admin does. Reactivating the account restores none of these sessions; its owner signs in again.

The same applies when people change or reset their own password: their other sessions end with the old password.

Each token carries a number the account's row also holds, and the change raises it, so the check compares the two rather than times. A server reads the row at most once every five seconds per account, so the instance that made the change refuses at once, and every other instance within the ten seconds. If the database cannot be read, the request fails rather than being answered as signed in or signed out. An installation upgrading to this release adds the column with nextly migrate, as for any new core column.

A deactivation also outlasts the account's own links. While an administrator has an account switched off, following its verification link verifies the address without activating the account, no new verification link is sent, and an invite link or a password-reset link is refused. Activating the account again lifts all of these.

What a token is for

Needs the release after 0.0.2-alpha.66.

Session tokens and the pending tokens of a second-factor exchange carry a typ header naming their purpose, and each is verified for one purpose only, so a pending token cannot be presented as a session, whatever else it carries. Preview tokens are kept apart by their own derived key and audience.

This release still accepts a session token with no typ header, so tokens already in circulation keep working. A later release will require it, which will stop Direct API tokens minted before this change; browser sessions are unaffected, because access tokens rotate every fifteen minutes.

Signing in with an external provider

Needs the release after 0.0.2-alpha.66.

A plugin that has authenticated someone elsewhere finishes the login through core rather than assembling a session itself, using ctx.auth.completeLogin. It applies the same account-state rules, hooks and audit trail as a password login, so a plugin cannot grant a session core would have refused.

A sign-in interrupted by a second factor resumes on the login page without a token ever appearing in a URL: the pending token travels in an HttpOnly cookie and the page asks the server which challenge is outstanding.

See plugin authentication for the full flow.

Super-admin setup

The first time someone visits /admin, the panel redirects them to /admin/setup (driven by GET /admin/api/auth/setup-status). The form creates the first user, assigns the built-in Super Admin role, seeds the core permission rows, and logs the new admin in -- all in a single CSRF-protected request.

A few things to note:

  • Setup is a one-shot. Once any user exists, POST /admin/api/auth/setup returns 403 SETUP_COMPLETE.
  • Passwords are validated for strength (length, character classes) before hashing -- see validatePasswordStrength for the exact rules.
  • Super Admin is a system role that bypasses every permission check. Hand it out sparingly.
  • The last active super-admin can't be removed. Deleting them, deactivating them, or taking the role from them is refused with 409 CONFLICT ("Make another user super-admin first"), whether the request comes from the admin, the REST API or the Direct API. A plugin may not make these writes to any super-admin account, so it gets 403 FORBIDDEN instead. Only an active account holding the Super Admin role itself counts: not one that is a super-admin through a role built on it, since that role's inheritance can be edited away, and not a deactivated account. To remove that person, first give another user the Super Admin role. Two removals sent at once can't remove both of the last two.
  • For programmatic / test seeding, seedSuperAdmin(adapter, { email, password, name? }) exists, but the recommended path is the setup wizard. It ships no default credentials -- email and password are required, and it throws a VALIDATION_ERROR if either is missing, so a known-weak default admin can never be created by accident. Pass throwaway values in tests and real, secret values anywhere else.

Roles

Roles are managed in the admin UI at Settings -> Roles (/admin/security/roles) or programmatically via RoleService. Each role has a name, slug, level, and optional description.

The seeded Super Admin role bypasses every check. Other roles you create are gated by their assigned permissions.

Checking roles in code

import { getNextly } from "nextly";
import {
  getSession,
  hasRole,
  hasAnyRole,
  hasAllRoles,
} from "nextly/auth";

import config from "../../../nextly.config";

export async function GET(req: Request) {
  await getNextly({ config });
  const result = await getSession(req, process.env.NEXTLY_SECRET ?? "");
  if (!result.authenticated) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const user = result.user;

  if (hasRole(user, "admin")) {
    // user has the "admin" role
  }

  if (hasAnyRole(user, ["admin", "editor"])) {
    // user has at least one of these roles
  }

  if (hasAllRoles(user, ["editor", "reviewer"])) {
    // user has both roles
  }
}

Permissions

Permissions follow the slug format <action>-<resource> -- e.g. read-users, create-roles, update-api-keys, manage-settings. The four standard actions are create, read, update, delete; some resources also use manage as a coarse-grained shortcut.

Permissions for the core resources (users, roles, permissions) are seeded the first time the database is set up. Permissions for collections and singles you define in nextly.config.ts are wired up by the collection / single registration path -- you don't need to add them to a seeder.

For the full RBAC reference (role hierarchy, the seeded permission inventory, sidebar gating), see the Admin overview.

Code-defined access control

You can attach access rules directly to collections and singles via defineCollection({ access }). Rules can be a boolean or a function that receives the request context. A declared rule is what a signed-in user is judged by, so it checks the role's permission itself and only narrows from there; Super Admin always bypasses everything. Don't decide by a role's name: a custom role, or an API key, that holds the permission would be refused.

// src/collections/posts.ts
import { defineCollection } from "nextly/config";

export default defineCollection({
  slug: "posts",
  access: {
    // Boolean: anyone, visitors included, can read
    read: true,

    // Function: the caller's role holds the permission (`create-posts`, ...)
    create: ({ permissions }) => permissions.includes("posts:create"),
    update: ({ permissions }) => permissions.includes("posts:update"),
    delete: ({ permissions }) => permissions.includes("posts:delete"),
  },
  fields: [
    // ...
  ],
});

API key authentication

Nextly supports API keys via the Authorization: Bearer nx_live_... header (no X-API-Key header). Keys are created and managed in the admin UI at Settings -> API Keys.

Token types

TypePermissions
read-onlyOnly the read-* slugs from the creator's roles.
full-accessAll permissions of the creator's roles.
role-basedPermissions of an explicitly chosen role (independent of the creator).

A key created by a Super Admin copies the whole permission catalogue rather than the role's rows, because the Super Admin's power is a bypass and the role only holds the permissions that existed when the install was set up: a read-only key of theirs can read every collection, including one added later, and still cannot write. A role that inherits Super Admin counts, the same way it counts everywhere else.

Keys can be time-bound (set an expiry date) and revoked at any time. The full key value is shown to the operator once at creation; the database only stores the SHA-256 hash.

How verification works

When a request includes a Bearer token, the auth middleware:

  1. Hashes the key with SHA-256 and looks it up in the api_keys table.
  2. Rejects expired or revoked keys.
  3. Enforces per-key rate limiting (default: 1,000 requests/hour, configurable via apiKeys.rateLimit in defineConfig()).
  4. Resolves the effective permission set based on the token type and creator's roles.
  5. Attaches an AuthContext with authMethod: "api-key" to the request.
// Client-side usage
const response = await fetch(
  "https://example.com/admin/api/collections/posts/entries",
  {
    headers: {
      Authorization: "Bearer nx_live_<random>...",
    },
  }
);

Security features

Brute-force protection

Failed logins are tracked per-user. After 5 consecutive failures, the account is locked for 15 minutes. Counters reset on successful login.

Login stall time

Every login response is held for at least 500ms regardless of whether the account exists. This prevents email enumeration via response timing.

Per-IP auth-rate-limit

A separate per-IP envelope rate-limits /auth/login, /auth/register, /auth/forgot-password, /auth/verify-email/resend, /auth/verify-email, /auth/reset-password, /auth/accept-invite, /auth/challenge/resolve and /auth/set-initial-password, sharing one budget per IP (default: 30 requests/hour, configurable via security.authRateLimit). This stops credential-stuffing attacks from a single source even before per-user lockout kicks in.

CSRF protection

All state-changing auth endpoints (login, logout, register, setup, password operations) use double-submit CSRF tokens plus Origin/Referer validation. Add additional allowed origins via the NEXTLY_ALLOWED_ORIGINS env var (comma-separated). A self-hosted server (next start or standalone) takes its own origin from the address it listens on, not from your public URL, so list the origin browsers use whenever it differs from that address — behind a proxy, by IP or hostname, or in a container — or sign-in and plugin-route writes are refused with CSRF_FAILED.

Refresh-token rotation

Needs the release after 0.0.2-alpha.66.

Every successful refresh consumes the old refresh token and issues a new one. A token that matches no row answers 401 REFRESH_FAILED without clearing the cookies, and logs a refresh-token-not-found warning (without the token). Watch that warning for replays of a consumed token, but not every one is theft: it also fires for a second tab a round trip behind a rotation another tab made, for a session that was revoked, and for a cookie holding a value that was never issued. The cookies are left alone because nothing is revoked there, and clearing them would sign out the tab that just rotated.

Two refreshes presenting the same token get one rotation. The one that loses the race answers 401 REFRESH_SUPERSEDED and leaves the cookies alone. The admin retries with the cookies it now holds; any other client should retry once with its current cookies.

Session revocation on password change and deactivation

Needs the release after 0.0.2-alpha.66.

Every password write ends the account's sessions: an administrator setting it (updateUser or PATCH /admin/api/users/:id/password), the user changing their own, a reset, accepting an invite, and setting an initial password. Deactivating an account does the same. Each deletes all of the account's refresh tokens in the same transaction as the write, forcing re-login on every device.

Password management

Passwords are hashed with bcryptjs (12 salt rounds). The AuthService and password handlers cover the full lifecycle:

  • registerUser() -- creates a new user with strength-validated password.
  • verifyCredentials() -- validates email + password for login.
  • changePassword() -- updates the password after verifying the current one; revokes all sessions.
  • generatePasswordResetToken() -- issues a single-use SHA-256-hashed token (24-hour expiry) and sends the reset email.
  • resetPasswordWithToken() -- consumes the token and updates the password.
  • generateEmailVerificationToken() / verifyEmail() -- email verification flow.

Raw tokens are returned to the user once (in the email link); only the hashes are stored.

Auth configuration

The auth block in defineConfig() currently exposes a single opt-in flag. The TTLs and brute-force settings listed below as defaults are wired in the runtime today; they are not yet exposed as user-tunable config keys.

// nextly.config.ts
import { defineConfig } from "nextly/config";

export default defineConfig({
  auth: {
    // When false (default), /auth/register returns the same "we've sent a
    // confirmation link" response whether or not the email exists. Set to
    // true only if you don't care about email enumeration (e.g. closed
    // admin tool with controlled signup).
    revealRegistrationConflict: false,
  },
});

Built-in defaults you can rely on (not configurable yet):

SettingDefault
Access-token TTL15 minutes
Refresh-token TTL7 days
Max login attempts5
Lockout duration15 minutes
Login stall time500ms
Bcrypt salt rounds12
Require email verificationtrue

Environment variables

VariableRequiredDescription
NEXTLY_SECRETyes (prod)JWT signing secret. Min 32 characters in production.
NEXTLY_ALLOWED_ORIGINSself-hostedComma-separated additional CSRF origins; include the origin browsers use whenever it differs from the address the server listens on.
NEXT_PUBLIC_APP_URLyes (prod)Public URL of the deployment, used in email links.

Generate NEXTLY_SECRET with:

openssl rand -base64 32

Auth endpoints

MethodPathDescription
GET/admin/api/auth/setup-statusHas the first user been created?
GET/admin/api/auth/sessionGet the current session.
GET/admin/api/auth/csrfMint a CSRF token.
GET/admin/api/auth/pendingWhich second factor an interrupted sign-in left outstanding. Never returns the token itself.
GET/admin/api/auth/uiThe login page's provider buttons, challenge views and slots.
POST/admin/api/auth/setupCreate the first super-admin (auto-logs in).
POST/admin/api/auth/loginEmail + password login.
POST/admin/api/auth/challenge/resolveAnswer a second factor with the pending token.
POST/admin/api/auth/set-initial-passwordReplace an administrator-set password at first sign-in.
POST/admin/api/auth/logoutRevoke the current session.
POST/admin/api/auth/refreshRotate access + refresh tokens.
POST/admin/api/auth/registerUser registration.
PATCH/admin/api/auth/change-passwordChange password (auth required).
POST/admin/api/auth/forgot-passwordRequest a password-reset email.
POST/admin/api/auth/reset-passwordReset password with the email token.
POST/admin/api/auth/accept-inviteSet a password from an invite link.
POST/admin/api/auth/verify-emailVerify email with the email token.
POST/admin/api/auth/verify-email/resendResend the verification email.

Next steps

  • Email -- configure the provider that sends password-reset and verification emails.
  • Admin overview -- the seeded permissions, sidebar gating, and per-route checks.
  • Collections -- attach access rules to your collections.
  • Configuration reference -- the full defineConfig() shape.
  • REST API -- request / response shapes for the auth endpoints.