Skip to Content
GuidesAuthentication

Authentication

This template uses Better Auth with the Drizzle adapter and database sessions. 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 profile and billing entry, /me/security for current database login management, and /admin for authorized operations
  • Server entrypoints: auth, resolveCurrentSubject, and currentLoginSignOut from ~/server/auth
  • Password helpers: requestRegistrationCode, registerWithEmailCode, requestPasswordReset, resetPasswordWithCode, and changeOwnPassword from ~/server/auth/password
  • Session fields: session.user.id, session.user.role, and session.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
  • /login
    • Signs in through native Better Auth signIn.email
    • Shows Discord, GitHub, and Google buttons only when their env vars are configured
  • /forgot-password
    • Accepts an email address and calls requestPasswordReset
  • /reset-password
    • Completes password recovery with email, code, and newPassword
  • /me
    • Requires an authenticated session and shows profile and billing entry
  • /me/security
    • Uses the current loginSessions capability to observe and revoke this account’s database logins; it does not turn retained old authorization records into current sessions
  • /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 current database session and reads the current account. It returns a fresh public session or null; unavailable storage throws instead of falling back to cached identity. Use resolveCurrentSubject() when you need explicit unauthenticated, account_unavailable and service_unavailable results. Only kind: "ready" carries current identity; resource ownership remains with its domain.

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 shared browser client is authClient from @t3template/sdk/auth. It uses the official Better Auth React client at /api/auth, with no session provider. authClient.useSession() returns data, isPending, error and refetch; only the public user fields are used for presentation. Suppress stale identity when error is present. Protected operations always recheck the server.

The shipped form adapts native sign-in through its guarded operation:

"use client"; import { signInWithEmail } from "@t3template/sdk/auth/sign-in"; export async function passwordSignIn(formData: FormData) { const result = await signInWithEmail(formData); if (result.kind === "authenticated") window.location.assign(result.destination); return result; }

The adapter invokes authClient.signIn.email({ email, password, callbackURL }). OAuth uses authClient.signIn.social({ provider, callbackURL, errorCallbackURL, disableRedirect: true }). The client disables automatic redirects so the operation can discard a late reply after the user stops waiting. Application callback paths are validated; external provider authorization URLs come only from the native endpoint.

Provider choices are discord, github and google, each enabled only by its configured ID/secret pair. Password sign-in uses actual stored credentials in every environment. Development account creation belongs to Bootstrap; there is no automatic development-credentials fallback.

OAuth is optional, including in production. Every actual login runtime requires an AUTH_SECRET of at least 32 characters; no third-party identity account is required for password login. Generate your own random secret rather than using documentation placeholders.

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. The shipped page notifies the Better Auth session store after a confirmed exit or local clear. Local cleanup and durable session revocation remain separate results.

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. The browser now calls native signIn.email. } 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 continue signing in with their configured provider; 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 Better Auth configuration, 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="generate-your-own-random-secret-at-least-32-characters" AUTH_LOGIN_REDIRECT="/me" 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_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_URL is the canonical auth origin for redirects and callback handling. Keep it aligned with the current local, preview, or production lane.
  • Login and register completion default to /me. AUTH_LOGIN_REDIRECT may select a supported safe internal destination; sensitive routes independently authorize requests. Retired example and account-center paths are rejected as return targets.
  • Keep the configured origin aligned with the trusted deployment entrance; do not treat request headers or client state as identity.

Current Runtime And Authority

~/server/auth owns server identity, account lifecycle and logout commands. The Next /api/auth handler mounts the selected Better Auth native endpoints. Native sign-up, reset, account updates and administrative mutations are restricted so they cannot bypass application proof, continuity and audit rules.

  • Database sessions are the current login source. Old JWT authorization records remain historical and do not authenticate requests.
  • auth() and resolveCurrentSubject() read current role/status on every protected operation. Disabled, deleted, revoked or unsupported accounts cannot rely on cached client fields.
  • PublicSession from ~/server/auth/types contains only expires and safe user fields. Public session reads omit session tokens, IDs, private account generations and credential snapshots.
  • Password reset/change and account-wide revocation invalidate sessions issued before the change. A refresh does not renew that authority.
  • Security owns operation permissions. An editor can enter the selected content administration surfaces; other account and commercial administration requires its corresponding permission. Menu visibility grants no authority.

Checked examples

The adjacent _examples/server-auth.ts and _examples/client-auth.ts keep the documented calls checked against public exports. Local operation tests cover safe return targets, native error classification, duplicate exclusion, cancelled replies and recovery. Integration tests use real native sign-in and current database sessions. These checks do not establish external OAuth consent or production email delivery.

Troubleshooting

Redirects Or OAuth Callbacks Use The Wrong Host

  • Set APP_URL to 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, and SMTP_FROM
  • Remember that non-production lanes use DEV_AUTH_CODE instead 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 token fields.
  • Refresh the browser’s displayed session after a role change. After a password reset or grant revocation, sign in again; the old session remains invalid.
  • Treat a storage outage as unavailable. Do not add a fallback that authorizes cached role/status claims.

Next Steps

Basic account profile

readOwnProfile() from ~/server/auth supplies the safe current-account fields id, name, email, image and hasPassword. Its result is ready, unauthenticated, account_unavailable or service_unavailable. The /me page consumes this query instead of user-table SQL. Each profile/password command independently checks current authority. The account keeps profile and Billing compact. /me/security uses loginSessions.list, revoke and observe for current database logins, requires confirmation before revoking another login, and sends the current login through /logout. Privacy requests have their own /me/privacy entry; they do not automatically export or erase domain data. Personal API credentials and notification preferences are not part of this account surface.