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/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, stateless verification (no database hit). Sent as the HTTP-only
__nextly_access_tokencookie. - 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:
| 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 { 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/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.- For programmatic / test seeding,
seedSuperAdmin()exists with overridable defaults, but the recommended path is the setup wizard. Defaults likeadmin@example.com/Admin@123456exist inseedSuperAdmin()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
| 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). |
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/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):
| 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 | optional | Comma-separated additional CSRF origins. |
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 | /api/auth/setup-status | Has the first user been created? |
| GET | /api/auth/session | Get the current session. |
| GET | /api/auth/csrf | Mint a CSRF token. |
| POST | /api/auth/setup | Create the first super-admin (auto-logs in). |
| POST | /api/auth/login | Email + password login. |
| POST | /api/auth/logout | Revoke the current session. |
| POST | /api/auth/refresh | Rotate access + refresh tokens. |
| POST | /api/auth/register | User registration. |
| PATCH | /api/auth/change-password | Change password (auth required). |
| POST | /api/auth/forgot-password | Request a password-reset email. |
| POST | /api/auth/reset-password | Reset password with the email token. |
| POST | /api/auth/verify-email | Verify email with the email token. |
| POST | /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.