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.

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 sk_...) 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, stateless verification (no database hit). Sent as the HTTP-only __nextly_access_token cookie.
  • Refresh token -- opaque random string, 7-day TTL, SHA-256 hashed in the database for revocation support. Sent as the HTTP-only refresh-token cookie.

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 -- if a previously-consumed token is replayed, that's a signal of theft.

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

Reading the session in code

// app/api/my-route/route.ts
import { getSession } from "@nextlyhq/nextly/auth";
import { env } from "@nextlyhq/nextly/lib/env";

export async function GET(req: Request) {
  const result = await getSession(req, 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 { requireAuth } from "@nextlyhq/nextly/auth";
import { env } from "@nextlyhq/nextly/lib/env";

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

Super-admin setup

The first time someone visits /admin, the panel redirects them to /admin/setup (driven by GET /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 /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.
  • For programmatic / test seeding, seedSuperAdmin() exists with overridable defaults, but the recommended path is the setup wizard. Defaults like admin@example.com / Admin@123456 exist in seedSuperAdmin() for tests; never use them in production.

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 {
  getSession,
  hasRole,
  hasAnyRole,
  hasAllRoles,
} from "@nextlyhq/nextly/auth";
import { env } from "@nextlyhq/nextly/lib/env";

export async function GET(req: Request) {
  const result = await getSession(req, 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 -- they take precedence over database permissions, but Super Admin always bypasses everything.

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

export default defineCollection({
  slug: "posts",
  access: {
    // Boolean: any authenticated user can read
    read: true,

    // Function: only admins and editors can create / update
    create: ({ roles }) => roles.includes("admin") || roles.includes("editor"),
    update: ({ roles }) => roles.includes("admin") || roles.includes("editor"),

    // Function: only admins can delete
    delete: ({ roles }) => roles.includes("admin"),
  },
  fields: [
    // ...
  ],
});

API key authentication

Nextly supports API keys via the Authorization: Bearer sk_... 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).

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/api/nextly/collections/posts/entries",
  {
    headers: {
      Authorization: "Bearer sk_live_abc123...",
    },
  }
);

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, and /auth/reset-password (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).

Refresh-token rotation

Every successful refresh consumes the old refresh token and issues a new one. Replays of a consumed token are detectable -- if you wire up monitoring on the refresh handler, you can flag these as potential theft.

Session revocation on password change

When a user changes their password, all of their refresh tokens are deleted, 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 "@nextlyhq/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_ORIGINSoptionalComma-separated additional CSRF origins.
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/api/auth/setup-statusHas the first user been created?
GET/api/auth/sessionGet the current session.
GET/api/auth/csrfMint a CSRF token.
POST/api/auth/setupCreate the first super-admin (auto-logs in).
POST/api/auth/loginEmail + password login.
POST/api/auth/logoutRevoke the current session.
POST/api/auth/refreshRotate access + refresh tokens.
POST/api/auth/registerUser registration.
PATCH/api/auth/change-passwordChange password (auth required).
POST/api/auth/forgot-passwordRequest a password-reset email.
POST/api/auth/reset-passwordReset password with the email token.
POST/api/auth/verify-emailVerify email with the email token.
POST/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.