Authentication
Better Auth with email and password, GitHub, Google and magic links, verification, password reset, account self-service and how routes are protected.
Authentication is provided by Better Auth, a framework-agnostic
TypeScript auth library. Sessions are stored in your database and exposed to Astro through
Astro.locals.
Anatomy#
| File | Role |
|---|---|
src/lib/auth.ts |
Server instance: providers, plugins, verification, rate limiting |
src/lib/auth-client.ts |
Browser client for React islands (magic link + admin plugins) |
src/lib/email.ts |
Email delivery and its production safety rule |
src/pages/api/auth/[...all].ts |
Catch-all route that mounts the Better Auth handler |
src/middleware.ts |
Verifies production config, resolves the session for on-demand requests |
src/db/schema/auth.ts |
Generated Drizzle schema (pnpm auth:generate) |
src/pages/login.astro, signup.astro |
Sign-in and sign-up pages (server rendered) |
src/pages/forgot-password.astro, reset-password.astro |
Password reset flow |
src/pages/dashboard.astro |
Protected page with account self-service |
src/pages/api/account/export.ts |
JSON export of the signed-in user’s data |
src/components/react/auth/*, react/account/* |
Forms: sign-in, sign-up, magic link, social, reset, verify, delete |
Sign-in methods#
- Email and password is always enabled (8 to 128 characters, with a show/hide toggle).
- GitHub and Google are registered automatically when
GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRETorGOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRETexist. The buttons only render for configured providers. Registerhttps://<your-domain>/api/auth/callback/github(or/google) as the callback URL with the provider. - Magic links use the
magicLinkplugin. The form only renders when email delivery is configured (see below). Signing in through a link marks the address as verified.
enabledAuthMethods() in src/lib/auth.ts tells pages which of these are active, so the
login copy and the “Forgot password?” link only mention what actually works.
Email delivery and the production rule#
Magic links, verification emails and password resets are sent through src/lib/email.ts
(Resend when RESEND_API_KEY is set). Without a key:
- in development and test (
NODE_ENVis notproduction) the message, including the link, is printed to the server console so every flow stays testable offline; - in production the send fails and the affected features are hidden or answer with an error. Sign-in links are bearer credentials and must never reach production logs.
The middleware also checks the production configuration once per server instance; see production configuration checks.
Email verification#
requireEmailVerification is enabled automatically when email delivery is configured and
off otherwise, so a fresh clone can still sign in. With verification on:
- sign-up sends a verification email and returns no session; the form tells the user to check their inbox;
- signing in with an unverified address fails and offers to resend the email;
- the dashboard shows the verification state and a “Send verification email” button;
- sign-up responses are identical for new and existing addresses (enumeration protection).
To force verification on without email (not recommended) set requireEmailVerification: true
explicitly in src/lib/auth.ts.
Password reset and change#
/forgot-password requests a reset link (authClient.requestPasswordReset), which lands on
/reset-password?token=… where the new password is set. Resetting or changing a password
(dashboard → Password) revokes every other session.
Account self-service and privacy#
The dashboard lets signed-in users:
- download their data as JSON (
/api/account/export: profile, sign-in methods, sessions and, for verified addresses, contact messages; never tokens or password hashes); - delete their account (
user.deleteUserin Better Auth). Password accounts confirm with their password; social-only accounts need a session younger than one day. TheafterDeletehook removes contact messages from a verified address and writes an audit entry.
See privacy and data for the policy template and retention job.
Administrators#
The admin plugin adds role, banned and
impersonation fields. Accounts whose address is listed in ADMIN_EMAILS receive the admin
role when they sign up (a database hook in src/lib/auth.ts); existing accounts are promoted
with pnpm admin:promote user@example.com. The admin area documents
what administrators can do.
Protecting routes#
Server-rendered pages read Astro.locals.user and redirect when it is missing:
---
export const prerender = false;
const { user } = Astro.locals;
if (!user) {
return Astro.redirect('/login?next=/dashboard');
}
---
Admin pages use guardAdminPage() from src/lib/admin-page.ts, which redirects anonymous
visitors and renders the 404 page for signed-in users without the role. It re-reads the session
from the database (see below) and refreshes Astro.locals with the result.
API routes and actions work the same way with context.locals.user. The ?next= parameter is
validated by safeRedirectPath() in src/lib/redirect.ts to prevent open redirects and is
preserved across the sign-in and sign-up pages.
Using the client#
import { authClient } from '@/lib/auth-client';
await authClient.signIn.email({ email, password, callbackURL: '/dashboard' });
await authClient.signIn.social({ provider: 'github', callbackURL: '/dashboard' });
await authClient.signIn.magicLink({ email, callbackURL: '/dashboard' });
await authClient.requestPasswordReset({ email, redirectTo: '/reset-password' });
await authClient.changePassword({ currentPassword, newPassword, revokeOtherSessions: true });
await authClient.deleteUser({ password, callbackURL: '/account-deleted' });
await authClient.signOut();
Better Auth’s client returns { data, error } and never throws for HTTP errors; the forms
still wrap calls in try/finally so a dropped connection resets the button state and shows
a retry message instead of spinning forever.
Sessions and cookies#
Sessions live in the session table and are cached in a signed cookie for five minutes
(session.cookieCache), so most requests never touch the database. Cookies are HttpOnly,
SameSite=Lax and Secure in production. Astro’s own session storage is disabled
(session: false); enable it per platform if you need Astro.session for unrelated state.
The cache is a display optimisation: the header or the dashboard may show a stale name or
role for up to five minutes after a change. Anything that decides access must not rely on
it, because a revoked session or a removed role would otherwise keep working until the cache
expires. getAuthoritativeSession() in src/lib/session.ts (Better Auth’s getSession with
disableCookieCache: true) reads the database instead; the admin pages, the admin actions and
the account export use it, and Better Auth’s own admin endpoints already do the same. Call it
in your own privileged routes:
import { getAuthoritativeSession } from '@/lib/session';
const { user } = await getAuthoritativeSession(Astro.request.headers);
Rate limiting and CSRF#
Rate limiting is enabled with database storage so it works on serverless platforms. The global
limit is 100 requests per minute per IP; Better Auth additionally applies its built-in stricter
rules to sign-in, sign-up, magic link, password and verification endpoints (3 requests per 10
or 60 seconds). The auth route forwards the client address resolved by the adapter as
x-forwarded-for. Astro’s security.checkOrigin rejects cross-origin POST requests, and
Better Auth additionally checks trustedOrigins, which are derived from BETTER_AUTH_URL,
BETTER_AUTH_TRUSTED_ORIGINS and the preview URLs each platform injects (see src/lib/env.ts).
Adding plugins#
Better Auth ships plugins for two-factor authentication, organisations, passkeys and more. Add
the plugin to src/lib/auth.ts, its client counterpart to src/lib/auth-client.ts, then
regenerate and migrate the schema:
pnpm auth:generate
pnpm db:generate
pnpm db:migrate