Authentication for Astro
Add email/password sign-up, Google sign-in, and protected pages to an Astro 5 site using Noma Project Auth. Keep most routes statically prerendered; only a handful of API endpoints and private pages need the server.
Static-by-default, server when you need it
Astro's island-first model is a great match for a content site with an authenticated area. Noma runs the auth backend on its servers; your Astro app exposes thin SSR endpoints at /api/auth/*, stores a signed cookie, and reads the current user in middleware so every page can access Astro.locals.user.
If you want self-hosted user tables, Astro pairs well with Better Auth. Use Noma instead when you want the auth backend, verification delivery hooks, and user-owned API keys without standing up a database. The noma-astro skill in nomacms-agent-skills captures the conventions in this guide.
Project, adapter, providers
- A Noma project at app.nomacms.com, with a verification policy chosen (strict mode blocks login until verified).
- An Astro adapter that supports SSR:
@astrojs/node,@astrojs/vercel,@astrojs/netlify, or@astrojs/cloudflarewith Node compat. - For Google sign-in: an OAuth 2.0 Client ID from the Google Cloud Console, allow-listed in your Noma project.
Background: Project Auth overview · Sign up & sign in · Social login.
SDK, env, adapter
npm install @nomacms/js-sdk @astrojs/nodeNOMA_PROJECT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
PUBLIC_GOOGLE_CLIENT_ID=xxxxxxxxxx.apps.googleusercontent.com// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
output: "static",
adapter: node({ mode: "standalone" }),
});output: "static" keeps your content pages prerendered. Auth endpoints and private pages opt into server rendering individually with export const prerender = false.
// src/env.d.ts
/// <reference types="astro/client" />
declare namespace App {
interface Locals {
user: { id: number; uuid: string; email: string; display_name?: string } | null;
}
}Extending App.Locals gives every .astro file typed access to Astro.locals.user.
One helper, one cookie
A tiny src/lib/session.ts module wraps the httpOnly cookie and returns an SDK client bound to the signed-in user.
// src/lib/session.ts
import type { AstroCookies } from "astro";
import { createClient, type AuthTokenResponse } from "@nomacms/js-sdk";
const COOKIE = "noma_session";
const projectId = import.meta.env.NOMA_PROJECT_ID!;
type StoredSession = {
accessToken: string;
refreshToken?: string;
expiresAt?: string;
};
export function saveSession(cookies: AstroCookies, res: AuthTokenResponse) {
if (!res.access_token) return;
const data: StoredSession = {
accessToken: res.access_token,
refreshToken: res.refresh_token,
expiresAt: res.expires_at,
};
cookies.set(COOKIE, JSON.stringify(data), {
httpOnly: true,
sameSite: "lax",
secure: import.meta.env.PROD,
path: "/",
maxAge: 60 * 60 * 24 * 30,
});
}
export function clearSession(cookies: AstroCookies) {
cookies.delete(COOKIE, { path: "/" });
}
export function readSession(cookies: AstroCookies): StoredSession | null {
const raw = cookies.get(COOKIE)?.value;
if (!raw) return null;
try {
return JSON.parse(raw) as StoredSession;
} catch {
return null;
}
}
export function getNomaUserClient(cookies: AstroCookies) {
const session = readSession(cookies);
if (!session) return null;
return createClient({
projectId,
projectUserAuth: {
accessToken: session.accessToken,
refreshToken: session.refreshToken,
autoRefresh: true,
},
});
}Attach user to every request
Middleware runs before each route. It loads Astro.locals.user from the cookie and redirects protected paths to the sign-in page when the session is missing.
// src/middleware.ts
import { defineMiddleware } from "astro:middleware";
import { getNomaUserClient, clearSession } from "@/lib/session";
const PROTECTED = [/^\/dashboard/, /^\/account/];
export const onRequest = defineMiddleware(async (context, next) => {
context.locals.user = null;
const noma = getNomaUserClient(context.cookies);
if (noma) {
try {
context.locals.user = (await noma.me()) as App.Locals["user"];
} catch {
clearSession(context.cookies);
}
}
const needsAuth = PROTECTED.some((re) => re.test(context.url.pathname));
if (needsAuth && !context.locals.user) {
return context.redirect("/sign-in");
}
return next();
});Form-post to SSR endpoints
// src/pages/api/auth/signup.ts
export const prerender = false;
import type { APIRoute } from "astro";
import { createClient } from "@nomacms/js-sdk";
import { saveSession } from "@/lib/session";
const noma = createClient({ projectId: import.meta.env.NOMA_PROJECT_ID! });
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
const form = await request.formData();
const result = await noma.signUp({
email: String(form.get("email")),
password: String(form.get("password")),
display_name: String(form.get("name") ?? "") || undefined,
});
if (result.verification_required) {
return redirect(`/verify?token=${result.verification_token}`);
}
saveSession(cookies, result);
return redirect("/dashboard");
};// src/pages/api/auth/signin.ts
export const prerender = false;
import type { APIRoute } from "astro";
import { createClient } from "@nomacms/js-sdk";
import { saveSession } from "@/lib/session";
const noma = createClient({ projectId: import.meta.env.NOMA_PROJECT_ID! });
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
const form = await request.formData();
const result = await noma.signInWithPassword({
email: String(form.get("email")),
password: String(form.get("password")),
});
saveSession(cookies, result);
return redirect("/dashboard");
};---
// src/pages/sign-in.astro
---
<form method="post" action="/api/auth/signin" class="space-y-3">
<input name="email" type="email" required placeholder="Email" />
<input name="password" type="password" required placeholder="Password" />
<button type="submit">Sign in</button>
</form>---
// src/pages/dashboard.astro
export const prerender = false;
const { user } = Astro.locals;
---
<h1>Welcome, {user?.display_name ?? user?.email}</h1>Email verification: when the project enforces it, the signup endpoint receives a verification_token. Deliver the link (your own email, SMS, or in-app channel) and confirm via noma.confirmVerificationEmail({ token }).
"Sign in with Google" with zero client JS
Google Identity Services can POST the ID token directly to your endpoint. No onclick handlers or fetch calls required. data-login_uri tells GIS where to send the credential; the endpoint exchanges it with Noma and redirects.
// src/pages/api/auth/social/google.ts
export const prerender = false;
import type { APIRoute } from "astro";
import { createClient } from "@nomacms/js-sdk";
import { saveSession } from "@/lib/session";
const noma = createClient({ projectId: import.meta.env.NOMA_PROJECT_ID! });
export const POST: APIRoute = async ({ request, cookies, redirect }) => {
const form = await request.formData();
const id_token = String(form.get("credential") ?? "");
const result = await noma.signInWithSocial({ provider: "google", id_token });
saveSession(cookies, result);
return redirect("/dashboard");
};---
// src/components/GoogleButton.astro
const clientId = import.meta.env.PUBLIC_GOOGLE_CLIENT_ID;
---
<div
id="g_id_onload"
data-client_id={clientId}
data-login_uri="/api/auth/social/google"
data-ux_mode="redirect"
></div>
<div class="g_id_signin" data-type="standard" data-theme="outline"></div>
<script is:inline src="https://accounts.google.com/gsi/client" async defer></script>Drop <GoogleButton /> on your sign-in page. Done. Because the button submits with data-ux_mode="redirect", the entire flow works without shipping any extra JavaScript from your app.
Revoke first, then clear the cookie
// src/pages/api/auth/signout.ts
export const prerender = false;
import type { APIRoute } from "astro";
import { getNomaUserClient, clearSession } from "@/lib/session";
export const POST: APIRoute = async ({ cookies, redirect }) => {
const noma = getNomaUserClient(cookies);
try {
if (noma) await noma.signOut();
} finally {
clearSession(cookies);
}
return redirect("/");
};Call this endpoint from a simple <form method="post" action="/api/auth/signout"> button. signOutAll() is available when you want every device signed out at once.
Before you ship
- Only the Google client id uses the
PUBLIC_prefix. The Noma project id and any keys stay server-only. - Cookies are
httpOnly,Secure,SameSite=Lax. - Sign-out calls
noma.signOut()beforeclearSession(). - Protected routes are marked
export const prerender = falseand covered by the middleware matcher. - Content pages remain prerendered; personalization moves into server islands so the CDN still caches article HTML.
- Handle
429(sign-in lockout, verification cooldown) with theretry_afterhint from Noma.