Type something to search...
How to add user authentication to an Astro site?

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/netlify or @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, or output: '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:

OptionTypeWhere users liveGood for
Better AuthOpen-source libraryYour databaseFull control, no per-user pricing
ClerkHosted service with @clerk/astroClerk's platformPrebuilt UI, quick setup, social logins
LuciaLearning resourceYour databaseUnderstanding and writing your own session code
ScalekitHosted serviceScalekit's platformB2B 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_SECRET and CLERK_SECRET_KEY must never be prefixed with PUBLIC_ 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.user itself.
  • Use HTTPS in production: auth cookies should be Secure, which browsers only honour over HTTPS.
  • Leave origin checks on: Astro's security.checkOrigin is on by default, and Better Auth validates the Origin header 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.pathname to 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.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper