Authentication
This template uses Auth.js v5 with the Drizzle adapter and a JWT session strategy. The shipped auth surface is Cloudflare-friendly and route-based: password login, optional OAuth providers, email-code registration, code-based password recovery, and server-side admin guards.
What Ships
- Public routes: /login, /register, /forgot-password, and /reset-password
- Authenticated routes: /me for self-service account management and /admin for admin-only tools
- Server entrypoints:
auth,resolveCurrentSubject,signIn, andcurrentLoginSignOutfrom~/server/auth - Password helpers:
requestRegistrationCode,registerWithEmailCode,requestPasswordReset,resetPasswordWithCode, andchangeOwnPasswordfrom~/server/auth/password - Session fields:
session.user.id,session.user.role, andsession.user.status - Built-in roles and statuses: user, admin, and editor plus active, disabled, and invited
Route Model
/register- Issues a six-digit email verification code, then atomically consumes it while creating a password account with
registerWithEmailCode
- Issues a six-digit email verification code, then atomically consumes it while creating a password account with
/login- Signs in with the
passwordprovider - Adds the
dev-credentialsprovider only indevelopment - Shows Discord, GitHub, and Google buttons only when their env vars are configured
- Signs in with the
/forgot-password- Accepts an email address and calls
requestPasswordReset
- Accepts an email address and calls
/reset-password- Completes password recovery with
email,code, andnewPassword
- Completes password recovery with
/me- Requires an authenticated session and lets the current user manage profile and password data
/admin- Uses server-side admin guards instead of trusting client-side route state
Read The Current User
Use auth() in Server Components, route handlers, and server actions:
Each call verifies the recorded login authorization and reads the current account. It returns a fresh public session or null; unavailable storage throws instead of falling back to JWT claims. Use resolveCurrentSubject() when you need explicit unauthenticated, account_unavailable and service_unavailable results. Only kind: "ready" carries current identity; resource ownership and organization permissions remain with their domains.
import { redirect } from "next/navigation";
import { auth } from "~/server/auth";
export default async function AccountPage() {
const session = await auth();
if (!session?.user?.id) {
redirect("/login");
}
return <div>Signed in as {session.user.email}</div>;
}Sign In
The password provider id is password, not a generic credentials alias:
"use server";
import { signIn } from "~/server/auth";
export async function passwordSignIn(email: string, password: string) {
await signIn("password", { email, password, redirectTo: "/me" });
}Provider ids currently used by the app:
passworddev-credentialsindevelopmentdiscordwhenAUTH_DISCORD_IDandAUTH_DISCORD_SECRETexistgithubwhenAUTH_GITHUB_IDandAUTH_GITHUB_SECRETexistgooglewhenAUTH_GOOGLE_IDandAUTH_GOOGLE_SECRETexist
OAuth is optional, including in production. AUTH_SECRET is required in production; no third-party identity account is required for password login.
Sign Out
Use the shipped /logout confirmation and recovery page, or invoke the Auth-owned operation from a Server Action:
"use server";
import { redirect } from "next/navigation";
import { currentLoginSignOut } from "~/server/auth";
export async function signOutCurrentLogin(confirmed: boolean) {
const result = await currentLoginSignOut.end({ confirmed });
if (result.kind === "signed_out") redirect("/login");
return result;
}end revokes only the originating recorded login, then clears its local cookie. Handle confirmation_required, service_unavailable, outcome_unknown and cookie_unavailable distinctly. observe() checks current authority without proving which earlier command caused it. The explicit clearLocal() fallback clears this browser only and makes no copied-token revocation guarantee. Raw Auth.js signOut is not exported by ~/server/auth.
Registration
/register first requests a code, then submits the code with the new account fields. The server generates the code and owns expiry, purpose, rate limits and password validation.
import { requestRegistrationCode, registerWithEmailCode } from "~/server/auth/password";
// Request phase: the email transport may accept, reject or have an unknown result.
const request = await requestRegistrationCode("user@example.com");
// Submission phase: code comes from the user-entered form, never a client verification flag.
const result = await registerWithEmailCode({ email: "user@example.com", code: userEnteredCode, password: "securepassword123", name: "Jane Doe" });
if (result.ok) {
// The account and single-use proof commit are confirmed. Sign in separately.
} else if (result.status === "outcome_unknown") {
// Offer sign-in or recovery before creating another registration attempt.
}The default persistence adapter couples account creation and exact proof consumption in one transactional batch. Duplicate email cannot replace an existing account. Invalid, expired, wrong-purpose and already-consumed codes do not create an account. New passwords use the same 8–128 character rule for registration, recovery and account password changes; password content is not trimmed.
accepted means the transport accepted the email, not that it reached the inbox. A definite not_sent result removes only that issuance; an unknown send result preserves it until expiry or explicit replacement. Use the latest requested code and do not blindly retry an unknown result.
In non-production lanes, the pages use DEV_AUTH_CODE and skip external email. You must still click Send to create the stored proof before registering. The fixed development code does not bypass proof issuance or consumption. Old standalone verifyEmailCode and registerPasswordUser helpers remain for trusted compatibility callers; composing them as two separate operations does not provide atomic account registration.
Password Recovery
Password recovery is code-based and uses the real helpers below:
import { requestPasswordReset, resetPasswordWithCode } from "~/server/auth/password";
await requestPasswordReset({ email: "user@example.com" });
await resetPasswordWithCode({ email: "user@example.com", code: "123456", newPassword: "newpassword123" });Production delivery needs working SMTP_* configuration. In non-production lanes the reset flow uses DEV_AUTH_CODE and does not send a real email, but still requires a prior recovery request.
Recovery requests use the same public accepted response for eligible, missing, OAuth-only and account-specific storage or delivery failures. This response does not confirm account existence or delivery. Input and rate-limit failures remain actionable. Identical response content is not a guarantee against timing analysis.
resetPasswordWithCode returns reset only after the password, proof consumption, security-history fact and account sessionVersion increment commit together. That increment invalidates every previously issued login grant, including the current login; sign in again after success. An unknown commit result requires checking sign-in with the new password before requesting another reset. A failed post-commit notification does not undo the new password or revocation. Accounts without a password hash use the separate first-password proof workflow under /me/security; recovery cannot install their first password.
Replace The Login Presentation
The shipped login card consumes useLoginOperation and LoginViewProps from the login route. A replacement view uses the same state and commands for editing, password submission, enabled OAuth methods, stopping a local wait and checking the current session. It does not own Auth.js, password policy, return-target validation or a second authentication implementation.
The operation keeps only email in local storage. Passwords stay transient. Duplicate pending submissions are blocked, late responses after cancellation do not navigate, and uncertain transport results offer a fresh server-side session check. The compact test renderer exercises the same protocol; it does not establish real OAuth or SMTP delivery evidence.
Protect Routes And Actions
For general authenticated surfaces, check auth() where the request is handled:
import { NextResponse } from "next/server";
import { auth } from "~/server/auth";
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return NextResponse.json({ userId: session.user.id, role: session.user.role, status: session.user.status });
}For admin-only surfaces, use the repository guard helpers:
import { requireAdmin } from "~/server/security/admin-auth";
export default async function AdminPage() {
await requireAdmin();
return <div>Admin tools</div>;
}The root middleware is not the auth-protection boundary. In this repository it enriches request headers for Cloudflare-aware logging and analytics, so protected pages, route handlers, and server actions must enforce auth themselves.
Configuration
The main environment surface is:
APP_URL="http://localhost:3101"
AUTH_SECRET="replace-me-with-a-local-secret"
AUTH_LOGIN_REDIRECT="/dashboard"
AUTH_DISCORD_ID="your-discord-client-id"
AUTH_DISCORD_SECRET="your-discord-client-secret"
AUTH_GITHUB_ID="your-github-client-id"
AUTH_GITHUB_SECRET="your-github-client-secret"
AUTH_GOOGLE_ID="your-google-client-id.apps.googleusercontent.com"
AUTH_GOOGLE_SECRET="your-google-client-secret"
DEV_AUTH_EMAIL="dev@example.com"
DEV_AUTH_PASSWORD="dev-password"
DEV_AUTH_NAME="Dev User"
DEV_AUTH_CODE="123456"
SMTP_HOST="smtp.example.com"
SMTP_PORT="587"
SMTP_USER="smtp-user"
SMTP_PASS="smtp-pass"
SMTP_FROM="Product <no-reply@example.com>"Notes:
APP_URLis the canonical auth origin for redirects and callback handling. Keep it aligned with the current local, preview, or production lane.AUTH_LOGIN_REDIRECTdefaults login and register completion to/dashboard. If you keep that default, protect any sensitive dashboard routes explicitly.AUTH_TRUST_HOSTexists in the env schema, but the current auth config already setstrustHost: true.
Current Auth.js Configuration
createAuthConfig() in src/server/auth/config.ts owns the request-local Auth.js configuration. src/server/auth/runtime/session.ts composes its handlers and fresh server identity operations; consume the public ~/server/auth exports instead of copying a second configuration. Password auth is always available. Optional OAuth providers are appended only when their env pairs are present.
Session And Authorization Truth
- Auth.js verifies the JWT; its fixed issuance version and login identity are then checked against current server records. A signed token alone is insufficient.
- A recorded login must be active and unexpired, and its issuance version must match the current account’s
sessionVersion. Legacy adapter session rows do not substitute for these login authorizations. auth()andresolveCurrentSubject()read the current role/status on each protected operation. Role changes apply on the next server check; disabled, deleted, unsupported or revoked accounts cannot rely on old claims. Browser presentation may need a refresh, but it is not an authorization boundary.- Public sessions omit private
sessionVersionandloginId. Never accept those fields, an actor ID or an asserted role from a form as authority. - Admin guards consume fresh Auth identity and apply the Security domain’s admin policy. General identity supports eligible
activeandinvitedaccounts; individual operations can require stronger conditions. - Password reset/change and account-wide revocation advance the account version. Refreshing an old token never upgrades it to the new version. Selective revocation and ordinary sign-out invalidate the selected login record.
Checked server examples
src/app/docs/guides/authentication/_examples/server-auth.ts contains compilable callers for fresh identity, password sign-in, current-login sign-out, reset completion and the admin guard. npm run check checks them against the actual exports. The existing src/server/auth/runtime/default.test.ts uses actual Auth.js, Next request context and disposable SQL to check fresh roles, disabled accounts and password-reset revocation; src/server/auth/sessions/logout.integration.test.ts checks current-login revocation and uncertain/local-only results. These local checks do not prove external OAuth or SMTP delivery.
Troubleshooting
Redirects Or OAuth Callbacks Use The Wrong Host
- Set
APP_URLto the exact lane origin you are running - Register OAuth callbacks as
${APP_URL}/api/auth/callback/<provider>
Password Reset Codes Do Not Arrive
- Configure
SMTP_HOST,SMTP_PORT,SMTP_USER,SMTP_PASS, andSMTP_FROM - Remember that non-production lanes use
DEV_AUTH_CODEinstead of sending real email
Permission Changes Do Not Show Up Right Away
- Confirm the protected operation calls the public server
auth()or the appropriate domain guard rather than cached client state or raw JWT fields. - Refresh the browser’s displayed session after a role change. After a password reset or grant revocation, sign in again; the old token remains invalid.
- Treat a storage outage as unavailable. Do not add a fallback that authorizes cached role/status claims.