How to use Own Auth with Astro
Run Own Auth in Astro SSR API routes and store its opaque session token in a Secure HttpOnly cookie. Middleware verifies the session and exposes safe user data through Astro.locals.
The implementation covers email and password sign-in, protected pages and endpoints, magic links, and immediate session revocation.
What you will build
- A server-only Own Auth instance
- Sign-up and sign-in API routes
- An opaque session token stored in an HttpOnly cookie
- Middleware that validates the session for each request
- Typed authentication data in Astro.locals
- Protected pages and API endpoints
- Magic-link request and verification routes
- Sign-out with immediate session revocation
How Own Auth fits into Astro
Browser form
-> Astro API route
-> own-auth
-> Postgres
The browser stores one opaque HttpOnly session token.
Middleware validates it and adds the result to Astro.locals.
Protected pages and API routes still enforce their own authorization.Install and migrate
Install Own Auth and a schema validator, then apply the Own Auth migrations to your Postgres database.
npm install own-auth zod
npx own-auth migrate
npx own-auth statusOwn Auth manages only its own auth tables. If the Astro project also uses Prisma, Drizzle, or another migration tool, keep each tool responsible for its own tables.
Configure environment values
DATABASE_URL=postgres://user:password@localhost:5432/myapp
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secret
APP_URL=http://localhost:4321Generate the token pepper once, store it with production secrets, and keep it stable. Changing it invalidates existing sessions, authentication links, SMS codes, and API keys.
Enable SSR and install an adapter
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
});The node adapter is shown here. Replace it with the adapter for your deployment target. The important setting is output: "server", which enables server-side rendering for all routes.
Create the Own Auth server module
Create a single shared Own Auth instance. Astro server code can access environment values through import.meta.env. Place the module in src/lib so it stays within the server boundary.
import { createOwnAuth } from 'own-auth';
const APP_URL = import.meta.env.APP_URL;
const OWN_AUTH_TOKEN_PEPPER = import.meta.env.OWN_AUTH_TOKEN_PEPPER;
if (!APP_URL) throw new Error('APP_URL is required');
if (!OWN_AUTH_TOKEN_PEPPER) {
throw new Error('OWN_AUTH_TOKEN_PEPPER is required');
}
export const auth = createOwnAuth({
tokenPepper: OWN_AUTH_TOKEN_PEPPER,
baseUrl: APP_URL,
});Centralize the session cookie
Own Auth returns an opaque session token and stores only its protected hash in Postgres. Put the raw token in a Secure HttpOnly cookie so browser JavaScript cannot read it.
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: '/' });
}Use Secure cookies in production and keep the path consistent when setting and deleting them. SameSite=Lax is a practical default for a first-party application while still allowing top-level navigation from an authentication email.
Validate the session in middleware
Astro middleware runs for every request. Read the cookie, verify the database-backed session with Own Auth, and place the result in Astro.locals. Delete an expired or revoked cookie so the browser stops sending it.
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 the locals type so all .astro pages and API routes receive the same current-session shape.
/// <reference types="astro/client" />
import type { auth } from './lib/auth';
declare namespace App {
interface Locals {
currentAuth: Awaited<ReturnType<typeof auth.getCurrentSession>>;
}
}Create the sign-in API route
Astro API routes export named handlers for each HTTP method. Validate the request body, call Own Auth, set the session cookie, and return a redirect or JSON response.
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();
const parsed = schema.safeParse(body);
if (!parsed.success) {
return new Response(
JSON.stringify({ 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 new Response(
JSON.stringify({ message: error.safeMessage }),
{ status: error.statusCode },
);
}
throw error;
}
setSessionCookie(
cookies,
result.sessionToken,
result.session.expiresAt,
);
return new Response(JSON.stringify({ redirect: '/dashboard' }), {
status: 200,
});
};Wrong emails and wrong passwords both produce Own Auth's generic invalid-credentials error. Preserve that ambiguity so the response does not reveal which accounts exist.
Build the sign-in form
An Astro page posts to the API route. The form script handles the fetch and redirect on the client side.
---
if (Astro.locals.currentAuth) {
return Astro.redirect('/dashboard');
}
---
<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>
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 (res.ok) {
window.location.href = json.redirect;
} else {
error.textContent = json.message;
}
});
</script>Add sign-up
The sign-up route follows the same pattern. Validate the name, email, and password, call signUpEmailPassword, set the returned session cookie, and return a redirect. Sign-up may report that an email already exists because the user must resolve that conflict.
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();
const parsed = schema.safeParse(body);
if (!parsed.success) {
return new Response(
JSON.stringify({ 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 new Response(
JSON.stringify({ message: error.safeMessage }),
{ status: error.statusCode },
);
}
throw error;
}
setSessionCookie(
cookies,
result.sessionToken,
result.session.expiresAt,
);
return new Response(JSON.stringify({ redirect: '/dashboard' }), {
status: 200,
});
};Protect pages and API endpoints
Check Astro.locals.currentAuth in the frontmatter of any page that requires authentication. Redirect unauthenticated visitors to the sign-in page. Return only the user fields the template needs. The session token must stay in the HttpOnly cookie and must not be rendered into the page.
---
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 a 401 response instead of a redirect.
import type { APIRoute } from 'astro';
export const GET: APIRoute = async ({ locals }) => {
if (!locals.currentAuth) {
return new Response(
JSON.stringify({ message: 'Not authenticated' }),
{ status: 401 },
);
}
return new Response(
JSON.stringify({ user: locals.currentAuth.user }),
{ status: 200 },
);
};Sign out and revoke the session
Revoke the database session before deleting the browser cookie. Use a POST route because sign-out changes server state and should not be a GET request.
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 new Response(JSON.stringify({ redirect: '/signin' }), {
status: 200,
});
};Add magic-link sign-in
Configure an Own Auth email provider, then add one API route to request the email and one to consume the one-time token. Always return the same confirmation regardless of whether an account exists.
import type { APIRoute } from 'astro';
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();
const parsed = schema.safeParse(body);
if (!parsed.success) {
return new Response(
JSON.stringify({ message: 'Enter a valid email address.' }),
{ status: 400 },
);
}
await auth.requestMagicLink({ email: parsed.data.email });
return new Response(
JSON.stringify({
message: 'If that address can sign in, a link is on its way.',
}),
{ status: 200 },
);
};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;
}
setSessionCookie(
cookies,
result.sessionToken,
result.session.expiresAt,
);
return redirect('/dashboard');
};Production checklist
- Use an SSR adapter and HTTPS in production
- Keep the Own Auth instance and all secret environment values in server-only modules
- Store the opaque session token only in a Secure HttpOnly cookie
- Verify the database session in middleware and each protected mutation
- Use the verified Own Auth user ID for ownership and authorization checks
- Use POST API routes for sign-in, sign-up, and sign-out
- Return generic errors for invalid credentials and magic-link requests
- Never serialize or log passwords, session tokens, provider credentials, or magic-link URLs
- Do not prerender authenticated pages
FAQ
Can I use Own Auth with a static Astro site?
Not directly. Own Auth needs a trusted server with Postgres access and a private token pepper. A static Astro site has no server runtime. Set output to "server" or "hybrid" in your Astro config and install an SSR adapter, or call a separate backend that runs Own Auth.
Should I store the session in a client-side variable?
No. Keep the raw session token in an HttpOnly cookie. Client-side JavaScript may hold non-secret user data passed from the server, but browser code should never receive the token.
Is checking the session in middleware enough?
It authenticates the request and makes the result available through Astro.locals. Each protected page and API endpoint must still require that result and enforce the user's authorization for the requested resource.
Can I use Own Auth in an Astro island component?
Island components with client:* directives run in the browser. They cannot import the Own Auth instance or access the session token. Pass only non-secret user data as props from the server-rendered .astro page, and have the island call your API routes for any authenticated actions.
Conclusion
The Astro integration keeps responsibilities clear. Own Auth owns authentication records and revocable database sessions. Astro owns API routes, middleware, cookies, routing, rendering, and authorization around application data. That separation keeps secrets off the client and avoids duplicating auth logic in the framework layer.