
How to add user authentication to an Astro site?
To add user authentication to an Astro site, install an adapter so pages can run on demand, choose an auth library such as Better Auth or a hosted service such as Clerk, mount the library's request handler on an API route, and use middleware to look up the current session on every request and store the user in Astro.locals. Pages then check Astro.locals.user and redirect to a login page when it's missing. Astro itself doesn't ship a login system, but it provides every piece one needs: on-demand rendering, cookies, middleware, endpoints and typed locals.
Authentication is the part of a web app where shortcuts hurt the most. Password hashing, session tokens, cookie flags, CSRF and rate limiting all have to be right, which is why the Astro docs point you to established libraries rather than a hand-rolled system. In this article you'll learn what Astro needs before you add auth, how the main options compare, how to build a complete email and password flow with Better Auth and the built-in node:sqlite module, how to read the user in pages, how sign-out works, and how Clerk handles the same job as a hosted service.
What Astro Needs for Authentication
Authentication is request-time work: the server must read a cookie, check a session and decide what to render. So before adding any library, make sure you have:
- An adapter: such as
@astrojs/node,@astrojs/vercel,@astrojs/netlifyor@astrojs/cloudflare. The post on Astro adapters helps you choose. - On-demand pages where auth matters: login, account and dashboard pages need
export const prerender = false, oroutput: 'server'for the whole site. Public marketing pages can stay prerendered. - A place to store users and sessions: a database for self-hosted libraries, or a third-party service for hosted ones.
The examples in this article were built and run on Astro 7.4.2 with @astrojs/node, Better Auth 1.7 and Node.js 26.
Choosing an Approach
The Astro docs list a few options. They fall into two groups:
| Option | Type | Where users live | Good for |
|---|---|---|---|
| Better Auth | Open-source library | Your database | Full control, no per-user pricing |
| Clerk | Hosted service with @clerk/astro | Clerk's platform | Prebuilt UI, quick setup, social logins |
| Lucia | Learning resource | Your database | Understanding and writing your own session code |
| Scalekit | Hosted service | Scalekit's platform | B2B and enterprise single sign-on |
Lucia is worth a note. It started as a library but is now a set of guides that explain how to implement sessions yourself, with Astro examples for GitHub OAuth, Google OAuth and email with two-factor authentication. It's an excellent way to learn how auth works, but most projects are better served by a maintained library.
If you want your users in your own database, start with Better Auth. If you'd rather not run any auth infrastructure, start with Clerk.
Building Email and Password Auth with Better Auth
Better Auth is a framework-agnostic TypeScript library. It works with Astro through a single catch-all API route plus a small middleware.
Step 1: Install and Configure
Install the package:
npm install better-auth
Generate a secret and add it to .env, along with the base URL of your site:
npx auth@latest secret
# .env
BETTER_AUTH_SECRET=paste-the-generated-value-here
BETTER_AUTH_URL=http://localhost:4321
auth is the Better Auth CLI package. The secret signs cookies and tokens, so keep it out of version control and set a different one in production.
Step 2: Create the Auth Instance
Better Auth accepts a DatabaseSync instance from Node's built-in node:sqlite module, so you don't need any extra database driver to get started:
// src/lib/auth.ts
import { betterAuth } from 'better-auth';
import { DatabaseSync } from 'node:sqlite';
export const auth = betterAuth({
database: new DatabaseSync('auth.sqlite'),
emailAndPassword: {
enabled: true,
},
});
node:sqlite requires Node.js 22.5 or later, which every Astro 7 project already meets. If you run astro check and see Cannot find name 'node:sqlite', install @types/node as a dev dependency. For hosted databases such as Postgres or MySQL, Better Auth has adapters for Drizzle, Prisma and other clients. The post on connecting Astro to a database covers those options.
Step 3: Create the Database Tables
The CLI reads your config and creates the tables Better Auth needs:
npx auth@latest migrate --config src/lib/auth.ts --yes
-> name, email, emailVerified, image, createdAt, updatedAt fields on user table.
-> expiresAt, token, createdAt, updatedAt, ipAddress, userAgent, userId fields on session table.
-> accountId, providerId, userId, accessToken, refreshToken, idToken, accessTokenExpiresAt, refreshTokenExpiresAt, scope, password, createdAt, updatedAt fields on account table.
-> identifier, value, expiresAt, createdAt, updatedAt fields on verification table.
The CLI lists the fields it will add, then confirms the migration. The four tables are user, session, account (which holds the hashed password for email sign-ins and tokens for social sign-ins) and verification. Run the command again whenever you enable a plugin that adds fields.
Step 4: Mount the API Route
Every Better Auth endpoint, from sign-up to sign-out, is handled by one catch-all route:
// src/pages/api/auth/[...all].ts
import type { APIRoute } from 'astro';
import { auth } from '../../../lib/auth';
export const prerender = false;
export const ALL: APIRoute = async (ctx) => {
return auth.handler(ctx.request);
};
Exporting ALL means the route answers every HTTP method. Better Auth recommends keeping the default /api/auth base path, because its client expects it.
Step 5: Load the Session in Middleware
Middleware runs before every on-demand page, so it's the natural place to look up the session once and share the result:
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
import { auth } from './lib/auth';
export const onRequest = defineMiddleware(async (context, next) => {
if (context.isPrerendered) return next();
const result = await auth.api.getSession({
headers: context.request.headers,
});
context.locals.user = result?.user ?? null;
context.locals.session = result?.session ?? null;
return next();
});
Add types for locals so pages get autocompletion:
// src/env.d.ts
/// <reference path="../.astro/types.d.ts" />
declare namespace App {
interface Locals {
user: import('better-auth').User | null;
session: import('better-auth').Session | null;
}
}
The inline import() types are deliberate. A top-level import statement would turn env.d.ts into a module and break the global App namespace declaration.
The context.isPrerendered check skips the lookup for prerendered pages, which have no visitor and no cookies at build time. You can read more about the pattern in what Astro middleware is and how to use it.
Step 6: Protect a Page
With the user in locals, a protected page is a few lines:
---
// src/pages/dashboard.astro
export const prerender = false;
const { user } = Astro.locals;
if (!user) {
return Astro.redirect('/login');
}
---
<html lang="en">
<body>
<h1>Welcome back, {user.name}</h1>
<p>Signed in as {user.email}</p>
</body>
</html>
The return is essential. Astro.redirect() only creates a response; returning it from the page's frontmatter is what stops the rest of the page from rendering. When you have many protected pages, move this check into middleware, as shown in protecting routes in Astro with middleware.
Step 7: Add the Client
Better Auth's client wraps the API routes in typed functions. Use the vanilla entry point in <script> tags, or the React, Vue, Svelte or Solid entry points inside framework components:
// src/lib/auth-client.ts
import { createAuthClient } from 'better-auth/client';
export const authClient = createAuthClient();
export const { signIn, signUp, signOut } = authClient;
A login page then submits the form through signIn.email():
---
// src/pages/login.astro
export const prerender = false;
if (Astro.locals.user) return Astro.redirect('/dashboard');
---
<html lang="en">
<body>
<form id="login">
<input type="email" name="email" required />
<input type="password" name="password" required minlength="8" />
<button>Sign in</button>
<p id="error" role="alert"></p>
</form>
<script>
import { signIn } from '../lib/auth-client';
const form = document.querySelector<HTMLFormElement>('#login')!;
const errorEl = document.querySelector('#error')!;
form.addEventListener('submit', async (event) => {
event.preventDefault();
const data = new FormData(form);
const { error } = await signIn.email({
email: String(data.get('email')),
password: String(data.get('password')),
});
if (error) {
errorEl.textContent = error.message ?? 'Sign-in failed';
return;
}
window.location.href = '/dashboard';
});
</script>
</body>
</html>
A sign-up page is the same shape, calling signUp.email({ name, email, password }). Signed-in visitors who land on /login are sent straight to the dashboard.
Testing the Flow
With the site built and running, the whole flow can be exercised with curl. A visitor without a session is redirected:
curl -i http://localhost:4321/dashboard
HTTP/1.1 302 Found
location: /login
Signing up returns the new user and sets a session cookie, which -c jar.txt saves:
curl -X POST http://localhost:4321/api/auth/sign-up/email \
-H 'Content-Type: application/json' \
-H 'Origin: http://localhost:4321' \
-d '{"name":"Ada","email":"ada@example.com","password":"correct-horse-battery"}' \
-c jar.txt
{"token":"fNRp2L...","user":{"name":"Ada","email":"ada@example.com","emailVerified":false,"image":null,...}}
Sending that cookie back renders the protected page:
<h1>Welcome back, Ada</h1><p>Signed in as ada@example.com</p>
A wrong password is rejected with a deliberately vague message, so attackers can't tell which half was wrong:
HTTP/1.1 401 UNAUTHORIZED
{"message":"Invalid email or password","code":"INVALID_EMAIL_OR_PASSWORD"}
The session cookie is called better-auth.session_token, and it's HttpOnly with SameSite=Lax, so client-side scripts can't read it.
Signing Out
Call signOut() from the client, then send the visitor somewhere public:
import { signOut } from '../lib/auth-client';
await signOut();
window.location.href = '/';
On the server, sign-out deletes the session row and clears the cookies:
HTTP/1.1 200 OK
set-cookie: better-auth.session_token=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax
set-cookie: better-auth.session_data=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax
{"success":true}
After that, the same cookie jar gets a 302 from /dashboard again. Because sessions live in the database, signing out really ends the session, rather than just hiding a cookie.
Using Clerk Instead
Clerk is a hosted alternative. You don't create tables or build forms; Clerk stores users and provides prebuilt sign-in components. According to Clerk's Astro quickstart, setup looks like this:
npm install @astrojs/node @clerk/astro
# .env
PUBLIC_CLERK_PUBLISHABLE_KEY=pk_...
CLERK_SECRET_KEY=sk_...
// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import clerk from '@clerk/astro';
export default defineConfig({
integrations: [clerk()],
adapter: node({ mode: 'standalone' }),
output: 'server',
});
// src/middleware.ts
import { clerkMiddleware } from '@clerk/astro/server';
export const onRequest = clerkMiddleware();
In pages and layouts you then use components from @clerk/astro/components, such as <Show when="signed-in">, <SignInButton /> and <UserButton />, and read the current user on the server with Astro.locals.auth(). The trade-off is clear: much less code and built-in social logins, in exchange for a monthly bill as you grow and user data held by a third party.
Security Checklist
Whichever library you choose, a few rules apply:
- Keep secrets on the server:
BETTER_AUTH_SECRETandCLERK_SECRET_KEYmust never be prefixed withPUBLIC_or imported into client code. The post on type-safe environment variables with astro:env shows how to enforce that. - Check authorisation on the server: hiding a button isn't protection. Every endpoint, action and page that changes or reveals private data must check
locals.useritself. - Use HTTPS in production: auth cookies should be
Secure, which browsers only honour over HTTPS. - Leave origin checks on: Astro's
security.checkOriginis on by default, and Better Auth validates theOriginheader against its trusted origins. When your site runs behind a proxy or on several domains, configure trusted origins rather than switching checks off. - Rate-limit login attempts: Better Auth has built-in rate limiting. When running behind a proxy, make sure the real client IP reaches it.
- Match routes, not strings: the Astro docs warn that comparing
context.url.pathnameto hard-coded strings can be bypassed with encoding tricks. Prefer per-page checks or route patterns.
FAQ: Authentication in Astro
No. Astro provides the building blocks, including on-demand rendering, cookies, middleware, endpoints and typed locals, and recommends libraries such as Better Auth or hosted services such as Clerk for the authentication itself.
You need an adapter and on-demand rendering for the pages that check the user, plus an API route for the auth library. The rest of the site can stay prerendered and public.
Lucia is now a set of guides that explain how to build sessions yourself, with Astro examples for OAuth and email sign-in. It's great for learning, but most projects should use a maintained library such as Better Auth.
Look up the session once in middleware and store the user in Astro.locals. Pages, endpoints and actions then read locals.user and redirect or throw an error when it's missing.
Yes. Better Auth accepts a DatabaseSync instance from Node's built-in node:sqlite module, which needs Node.js 22.5 or later. Run the Better Auth CLI migrate command to create the tables.
Choose Better Auth if you want users in your own database and no per-user cost. Choose Clerk if you want prebuilt sign-in UI, social logins and no auth infrastructure to run, and are comfortable with a hosted service.
Conclusion
Adding authentication to Astro follows the same pattern whichever library you pick: enable on-demand rendering with an adapter, mount the library's handler on an API route, load the session in middleware into Astro.locals, and check locals.user before rendering anything private. With Better Auth and node:sqlite, that takes a handful of small files and gives you real, database-backed sessions you control.
If you'd rather not run auth yourself, Clerk's integration replaces most of that code with a middleware call and prebuilt components. Either way, keep secrets on the server, authorise every request that touches private data, and let the library handle the hard parts like password hashing and token rotation.


