Skip to content
Strata

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_SECRET or GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET exist. The buttons only render for configured providers. Register https://<your-domain>/api/auth/callback/github (or /google) as the callback URL with the provider.
  • Magic links use the magicLink plugin. 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_ENV is not production) 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.deleteUser in Better Auth). Password accounts confirm with their password; social-only accounts need a session younger than one day. The afterDelete hook 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.

Do not guard by pathname in middleware

Astro’s docs warn that the pathname a middleware sees is not guaranteed to match the route Astro resolves (encoding, duplicate slashes, base). Keep authorization in the page or endpoint itself, or use a router such as Hono via Astro’s advanced routing for whole sections.

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