Back to guides

Own Auth with Astro


Call Own Auth from Astro API routes and keep the opaque session token in an HttpOnly cookie. Middleware verifies the session and exposes the current user through Astro.locals.

Install Own Auth

Terminal
npm install own-auth zod @astrojs/node

Apply the database schema

.env
DATABASE_URL=postgres://user:password@localhost:5432/myapp
Terminal
npx own-auth migrate

Enable SSR

astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  output: 'server',
  adapter: node({ mode: 'standalone' }),
});

The Node adapter runs API routes, middleware, and authenticated pages on the server.

Create the auth instance

Add a stable token pepper, then create the Own Auth instance in a server module that client scripts and hydrated islands do not import.

.env
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secret
src/lib/auth.ts
import { createOwnAuth } from 'own-auth';

const OWN_AUTH_TOKEN_PEPPER =
  import.meta.env.OWN_AUTH_TOKEN_PEPPER as string;

export const auth = createOwnAuth({
  tokenPepper: OWN_AUTH_TOKEN_PEPPER,
});

Create the session cookie

Use one helper to set and delete the HttpOnly session cookie.

src/lib/session-cookie.ts
import type { AstroCookies } from 'astro';

export const SESSION_COOKIE = 'own_auth_session';

const cookieOptions = {
  path: '/',
  httpOnly: true,
  sameSite: 'lax' as const,
  secure: import.meta.env.PROD,
};

export function setSessionCookie(
  cookies: AstroCookies,
  token: string,
  expires: Date,
) {
  cookies.set(SESSION_COOKIE, token, {
    ...cookieOptions,
    expires,
  });
}

export function deleteSessionCookie(cookies: AstroCookies) {
  cookies.delete(SESSION_COOKIE, { path: '/' });
}

Validate the session in middleware

Read the cookie in middleware and call auth.getCurrentSession. Store the result in Astro.locals, and delete the cookie after an expired or revoked session.

src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
import { auth } from './lib/auth';
import { deleteSessionCookie, SESSION_COOKIE } from './lib/session-cookie';

export const onRequest = defineMiddleware(async (context, next) => {
  const token = context.cookies.get(SESSION_COOKIE)?.value;
  context.locals.currentAuth = token
    ? await auth.getCurrentSession(token)
    : null;

  if (token && !context.locals.currentAuth) {
    deleteSessionCookie(context.cookies);
  }

  return next();
});

Declare currentAuth on App.Locals for pages and API routes.

src/env.d.ts
/// <reference types="astro/client" />

import type { CurrentSession } from 'own-auth';

declare namespace App {
  interface Locals {
    currentAuth: CurrentSession | null;
  }
}

Create the sign-in API route

Validate the JSON body and call auth.signInEmailPassword. A complete result sets the session cookie. An MFA result returns its challenge token, methods, and expiry with a successful response.

src/pages/api/signin.ts
import type { APIRoute } from 'astro';
import { AuthError } from 'own-auth';
import { z } from 'zod';
import { auth } from '../../lib/auth';
import { setSessionCookie } from '../../lib/session-cookie';

const schema = z.object({
  email: z.string().trim().email(),
  password: z.string().min(1),
});

export const POST: APIRoute = async ({ request, cookies }) => {
  const body = await request.json().catch(() => null);
  const parsed = schema.safeParse(body);

  if (!parsed.success) {
    return Response.json(
      { message: 'Enter a valid email and password.' },
      { status: 400 },
    );
  }

  let result;
  try {
    result = await auth.signInEmailPassword(parsed.data);
  } catch (error) {
    if (error instanceof AuthError) {
      return Response.json(
        { message: error.safeMessage },
        { status: error.statusCode },
      );
    }
    throw error;
  }

  if (result.status === 'mfa_required') {
    return Response.json({
      status: result.status,
      challengeToken: result.challengeToken,
      methods: result.methods,
      expiresAt: result.expiresAt,
    });
  }

  setSessionCookie(
    cookies,
    result.sessionToken,
    result.session.expiresAt,
  );

  return Response.json({ redirect: '/dashboard' }, {
    status: 200,
  });
};

Build the sign-in form

Post the form with fetch and follow the route's redirect value without reloading the document.

src/pages/signin.astro
---
import { ClientRouter } from 'astro:transitions';

if (Astro.locals.currentAuth) {
  return Astro.redirect('/dashboard');
}
---

<head>
  <ClientRouter />
</head>

<form id="signin-form">
  <label>
    Email
    <input name="email" type="email" autocomplete="email" required />
  </label>

  <label>
    Password
    <input
      name="password"
      type="password"
      autocomplete="current-password"
      required
    />
  </label>

  <p id="error" role="alert"></p>
  <button type="submit">Sign in</button>
</form>

<script>
  import { navigate } from 'astro:transitions/client';

  const form = document.getElementById('signin-form') as HTMLFormElement;
  const error = document.getElementById('error') as HTMLParagraphElement;

  form.addEventListener('submit', async (e) => {
    e.preventDefault();
    const data = Object.fromEntries(new FormData(form));

    const res = await fetch('/api/signin', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });

    const json = await res.json();

    if (json.status === 'mfa_required') {
      error.textContent = 'Continue with an additional verification method.';
    } else if (res.ok) {
      await navigate(json.redirect);
    } else {
      error.textContent = json.message;
    }
  });
</script>

Add sign-up

The sign-up route validates the request shape, calls auth.signUpEmailPassword, and sets the returned session cookie.

src/pages/api/signup.ts
import type { APIRoute } from 'astro';
import { AuthError } from 'own-auth';
import { z } from 'zod';
import { auth } from '../../lib/auth';
import { setSessionCookie } from '../../lib/session-cookie';

const schema = z.object({
  name: z.string().trim().min(1),
  email: z.string().trim().email(),
  password: z.string().min(1),
});

export const POST: APIRoute = async ({ request, cookies }) => {
  const body = await request.json().catch(() => null);
  const parsed = schema.safeParse(body);

  if (!parsed.success) {
    return Response.json(
      { message: 'Name, email, and password are required.' },
      { status: 400 },
    );
  }

  let result;
  try {
    result = await auth.signUpEmailPassword(parsed.data);
  } catch (error) {
    if (error instanceof AuthError) {
      return Response.json(
        { message: error.safeMessage },
        { status: error.statusCode },
      );
    }
    throw error;
  }

  setSessionCookie(
    cookies,
    result.sessionToken,
    result.session.expiresAt,
  );

  return Response.json({ redirect: '/dashboard' }, {
    status: 201,
  });
};

Protect pages and API endpoints

Require Astro.locals.currentAuth in protected page frontmatter and pass only the user to the template.

src/pages/dashboard.astro
---
const { currentAuth } = Astro.locals;

if (!currentAuth) {
  return Astro.redirect('/signin');
}

const { user } = currentAuth;
---

<h1>Dashboard</h1>
<p>Welcome, {user.name}</p>

Protect API endpoints the same way. Return 401 when the session is missing, and select only id, email, name, and imageUrl for the response.

src/pages/api/me.ts
import type { APIRoute } from 'astro';

export const GET: APIRoute = async ({ locals }) => {
  if (!locals.currentAuth) {
    return Response.json(
      { message: 'Not authenticated' },
      { status: 401 },
    );
  }

  const { id, email, name, imageUrl } = locals.currentAuth.user;
  return Response.json({ user: { id, email, name, imageUrl } });
};

Sign out

Call auth.signOut from a POST route, then delete the browser cookie.

src/pages/api/signout.ts
import type { APIRoute } from 'astro';
import { auth } from '../../lib/auth';
import {
  deleteSessionCookie,
  SESSION_COOKIE,
} from '../../lib/session-cookie';

export const POST: APIRoute = async ({ cookies }) => {
  const token = cookies.get(SESSION_COOKIE)?.value;

  try {
    if (token) await auth.signOut(token);
  } finally {
    deleteSessionCookie(cookies);
  }

  return Response.json({ redirect: '/signin' }, {
    status: 200,
  });
};

Add magic links

Magic-link generation needs the application origin. Add APP_URL, then replace the auth initialization in src/lib/auth.ts with the version below.

.env
APP_URL=http://localhost:4321
src/lib/auth.ts
const APP_URL = import.meta.env.APP_URL as string;

export const auth = createOwnAuth({
  tokenPepper: OWN_AUTH_TOKEN_PEPPER,
  baseUrl: APP_URL,
});

After configuring an email provider, add one route to request a magic link and another to verify its token. The request route returns the same confirmation for every email. Complete verification sets the session cookie; MFA returns its challenge payload.

src/pages/api/magic-link/request.ts
import type { APIRoute } from 'astro';
import { AuthError } from 'own-auth';
import { z } from 'zod';
import { auth } from '../../../lib/auth';

const schema = z.object({
  email: z.string().trim().email(),
});

export const POST: APIRoute = async ({ request }) => {
  const body = await request.json().catch(() => null);
  const parsed = schema.safeParse(body);

  if (!parsed.success) {
    return Response.json(
      { message: 'Enter a valid email address.' },
      { status: 400 },
    );
  }

  try {
    await auth.requestMagicLink({ email: parsed.data.email });
  } catch (error) {
    if (error instanceof AuthError) {
      return Response.json(
        { message: error.safeMessage },
        { status: error.statusCode },
      );
    }
    throw error;
  }

  return Response.json(
    {
      message: 'If that address can sign in, a link is on its way.',
    },
    { status: 200 },
  );
};
src/pages/auth/magic-link/verify.ts
import type { APIRoute } from 'astro';
import { AuthError } from 'own-auth';
import { auth } from '../../../lib/auth';
import { setSessionCookie } from '../../../lib/session-cookie';

export const GET: APIRoute = async ({ url, cookies, redirect }) => {
  const token = url.searchParams.get('token');
  if (!token) return redirect('/signin?error=invalid_link');

  let result;
  try {
    result = await auth.verifyMagicLink({ token });
  } catch (error) {
    if (error instanceof AuthError) {
      return redirect('/signin?error=invalid_link');
    }
    throw error;
  }

  if (result.status === 'mfa_required') {
    return Response.json({
      status: result.status,
      challengeToken: result.challengeToken,
      methods: result.methods,
      expiresAt: result.expiresAt,
    });
  }

  setSessionCookie(
    cookies,
    result.sessionToken,
    result.session.expiresAt,
  );

  return redirect('/dashboard');
};