# 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](#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](#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.

```typescript
// 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

```typescript
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](https://nextlyhq.com/docs/plugins/auth.md) 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

```typescript
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](https://nextlyhq.com/docs/admin.md#permissions).

## 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.

```typescript
// 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:

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.

```typescript
// 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.

```typescript
// 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:

```bash
openssl rand -base64 32
```

## Auth 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](https://nextlyhq.com/docs/guides/email.md) -- configure the provider that sends password-reset and verification emails.
* [Admin overview](https://nextlyhq.com/docs/admin.md#permissions) -- the seeded permissions, sidebar gating, and per-route checks.
* [Collections](https://nextlyhq.com/docs/configuration/collections.md) -- attach access rules to your collections.
* [Configuration reference](https://nextlyhq.com/docs/configuration/nextly-config.md) -- the full `defineConfig()` shape.
* [REST API](https://nextlyhq.com/docs/api-reference/rest-api.md) -- request / response shapes for the auth endpoints.
