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/setupthat 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_sessioncookie. - Refresh token -- opaque random string, 7-day TTL, SHA-256 hashed in the database for revocation support. Sent as the HTTP-only
nextly_refreshcookie, scoped to/admin/api/authso 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:
| Field | Type | Description |
|---|---|---|
id | string | User's unique ID |
email | string | User's email address |
name | string | Display name |
image | string? | Profile image URL |
roleIds | string[] | 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/setupreturns403 SETUP_COMPLETE. - Passwords are validated for strength (length, character classes) before hashing -- see
validatePasswordStrengthfor the exact rules. Super Adminis 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 gets403 FORBIDDENinstead. Only an active account holding theSuper Adminrole 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 theSuper Adminrole. 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 --emailandpasswordare required, and it throws aVALIDATION_ERRORif 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
| Type | Permissions |
|---|---|
read-only | Only the read-* slugs from the creator's roles. |
full-access | All permissions of the creator's roles. |
role-based | Permissions 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:
- Hashes the key with SHA-256 and looks it up in the
api_keystable. - Rejects expired or revoked keys.
- Enforces per-key rate limiting (default: 1,000 requests/hour, configurable via
apiKeys.rateLimitindefineConfig()). - Resolves the effective permission set based on the token type and creator's roles.
- Attaches an
AuthContextwithauthMethod: "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):
| Setting | Default |
|---|---|
| Access-token TTL | 15 minutes |
| Refresh-token TTL | 7 days |
| Max login attempts | 5 |
| Lockout duration | 15 minutes |
| Login stall time | 500ms |
| Bcrypt salt rounds | 12 |
| Require email verification | true |
Environment variables
| Variable | Required | Description |
|---|---|---|
NEXTLY_SECRET | yes (prod) | JWT signing secret. Min 32 characters in production. |
NEXTLY_ALLOWED_ORIGINS | self-hosted | Comma-separated additional CSRF origins; include the origin browsers use whenever it differs from the address the server listens on. |
NEXT_PUBLIC_APP_URL | yes (prod) | Public URL of the deployment, used in email links. |
Generate NEXTLY_SECRET with:
openssl rand -base64 32Auth endpoints
| Method | Path | Description |
|---|---|---|
| GET | /admin/api/auth/setup-status | Has the first user been created? |
| GET | /admin/api/auth/session | Get the current session. |
| GET | /admin/api/auth/csrf | Mint a CSRF token. |
| GET | /admin/api/auth/pending | Which second factor an interrupted sign-in left outstanding. Never returns the token itself. |
| GET | /admin/api/auth/ui | The login page's provider buttons, challenge views and slots. |
| POST | /admin/api/auth/setup | Create the first super-admin (auto-logs in). |
| POST | /admin/api/auth/login | Email + password login. |
| POST | /admin/api/auth/challenge/resolve | Answer a second factor with the pending token. |
| POST | /admin/api/auth/set-initial-password | Replace an administrator-set password at first sign-in. |
| POST | /admin/api/auth/logout | Revoke the current session. |
| POST | /admin/api/auth/refresh | Rotate access + refresh tokens. |
| POST | /admin/api/auth/register | User registration. |
| PATCH | /admin/api/auth/change-password | Change password (auth required). |
| POST | /admin/api/auth/forgot-password | Request a password-reset email. |
| POST | /admin/api/auth/reset-password | Reset password with the email token. |
| POST | /admin/api/auth/accept-invite | Set a password from an invite link. |
| POST | /admin/api/auth/verify-email | Verify email with the email token. |
| POST | /admin/api/auth/verify-email/resend | Resend 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.
Production migrations
Ship database schema changes to production safely with the Nextly migration CLI. Forward-only model, GitHub Actions, Vercel build step, and other-platform patterns.
Media & Storage
Configure storage adapters (local disk, S3, Vercel Blob, Uploadthing) and named image sizes for uploaded media.