Add Authentication

Email magic-link login with real, cookie-based sessions, the right way. Protect a page so only logged-in users can see it.

Your waitlist became a product. Now people need to log in. This guide adds email magic-link authentication: the user types their email, gets a link, clicks it, and they’re in. No passwords to store, reset, or leak.

We use Astro (server-rendered) + Supabase Auth. The important part most tutorials get wrong: sessions live in HTTP-only cookies on the server, not in localStorage. That’s what makes a login survive a refresh, work across tabs, and stay safe from cross-site scripting (XSS, where malicious JS steals your tokens). We’ll do it right.

What you’ll have at the end

  • A /login page that emails a magic link.
  • A real session stored in secure cookies, read on the server on every request.
  • A protected /dashboard page that redirects strangers to /login.
  • A working sign-out.

Before you start

  • Node 18+, a terminal.
  • A Supabase account → https://supabase.com (free tier is fine).
  • ~1–2 hours.

This guide builds a fresh app. If you did Build a Waitlist Page first, the Supabase setup will feel familiar, you can reuse the same project.


Step 1 — Create an Astro app (server-rendered)

npm create astro@latest auth-app
cd auth-app

Pick Empty, TypeScript: Strict, install deps: yes.

Auth needs the server to run on every request (to read the session cookie), so add a server adapter and switch Astro to server output:

npx astro add node
npm install @supabase/supabase-js @supabase/ssr

Open astro.config.mjs and confirm it looks like this (the astro add node step writes the adapter; add output: "server" yourself):

import { defineConfig } from "astro/config";
import node from "@astrojs/node";

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

Why output: "server"? It makes every route run on the server by default, so your pages can read the logged-in user. (For deploy on Cloudflare/Vercel, swap @astrojs/node for that host’s adapter, Step 7.)


Step 2 — Set up Supabase Auth

  1. https://supabase.comNew project (or reuse your waitlist project). Wait for it to spin up.
  2. Authentication → Providers → Email: make sure Email is enabled. Leave “Confirm email” on. (Magic link is part of the Email provider, nothing extra to turn on.)
  3. Authentication → URL Configuration: this is the step everyone forgets.
    • Site URL: http://localhost:4321
    • Redirect URLs: add http://localhost:4321/api/auth/callback
    • You’ll add your production URL here too in Step 7.
  4. Project Settings → API: copy the Project URL and the anon public key.

Step 3 — Add your config

Create .env:

PUBLIC_SUPABASE_URL=https://abcd1234.supabase.co
PUBLIC_SUPABASE_ANON_KEY=your-anon-public-key

Wait, PUBLIC_? Didn’t the waitlist guide say never do that? Different situation. The Supabase anon key is designed to be public, it identifies your project but grants nothing on its own (Row Level Security guards your data). Auth needs this key in a few client-safe spots, so PUBLIC_ is correct here. The thing you must NEVER expose is the service_role key. We don’t use it.

Make sure .env is gitignored (Astro’s template does this).


Step 4 — One server-side Supabase client

This helper reads the session from the request’s cookies and writes updated cookies back. Create src/lib/supabase.ts:

import { createServerClient, parseCookieHeader } from "@supabase/ssr";
import type { AstroCookies } from "astro";

// Builds a Supabase client bound to THIS request's cookies. The cookie adapter is
// how the session travels: read from the incoming Cookie header, written back via
// Astro's cookie API when Supabase refreshes the session.
export function createSupabaseServerClient(ctx: {
  request: Request;
  cookies: AstroCookies;
}) {
  return createServerClient(
    import.meta.env.PUBLIC_SUPABASE_URL,
    import.meta.env.PUBLIC_SUPABASE_ANON_KEY,
    {
      cookies: {
        getAll() {
          return parseCookieHeader(ctx.request.headers.get("Cookie") ?? "");
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) =>
            ctx.cookies.set(name, value, options),
          );
        },
      },
    },
  );
}

Step 5 — Make the user available on every request (middleware)

Astro middleware runs before every page/endpoint. We use it to load the current user once and hand it to every route via Astro.locals. Create src/middleware.ts:

import { defineMiddleware } from "astro:middleware";
import { createSupabaseServerClient } from "./lib/supabase";

export const onRequest = defineMiddleware(async (context, next) => {
  const supabase = createSupabaseServerClient(context);
  context.locals.supabase = supabase;

  // getUser() re-validates the token with Supabase — trustworthy enough to gate pages on.
  const {
    data: { user },
  } = await supabase.auth.getUser();
  context.locals.user = user;

  return next();
});

Tell TypeScript about those locals (and the env vars). Create src/env.d.ts:

/// <reference types="astro/client" />
import type { SupabaseClient, User } from "@supabase/supabase-js";

declare namespace App {
  interface Locals {
    supabase: SupabaseClient;
    user: User | null;
  }
}

interface ImportMetaEnv {
  readonly PUBLIC_SUPABASE_URL: string;
  readonly PUBLIC_SUPABASE_ANON_KEY: string;
}
interface ImportMeta {
  readonly env: ImportMetaEnv;
}

Step 6 — The login flow

Three small pieces: a login page, the endpoint that sends the magic link, and the callback that turns the clicked link into a session.

6a. Login page — src/pages/login.astro

---
// Already logged in? Skip straight to the app.
if (Astro.locals.user) return Astro.redirect("/dashboard");

const sent = Astro.url.searchParams.has("sent");
const error = Astro.url.searchParams.get("error");
---

<html lang="en">
  <head><meta charset="utf-8" /><title>Log in</title></head>
  <body style="font-family:system-ui;max-width:24rem;margin:6rem auto;padding:0 1rem">
    <h1>Log in</h1>

    {sent ? (
      <p>Check your email for a login link. You can close this tab.</p>
    ) : (
      <form method="POST" action="/api/auth/login">
        <label for="email">Email</label><br />
        <input id="email" name="email" type="email" required placeholder="you@example.com"
               style="width:100%;padding:.5rem;margin:.5rem 0" />
        <button type="submit" style="padding:.5rem 1rem">Send magic link</button>
      </form>
    )}

    {error && <p style="color:#c0392b">Something went wrong. Try again.</p>}
  </body>
</html>
import type { APIRoute } from "astro";
import { createSupabaseServerClient } from "../../../lib/supabase";

export const prerender = false;

export const POST: APIRoute = async (context) => {
  const form = await context.request.formData();
  const email = String(form.get("email") ?? "").trim().toLowerCase();

  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return context.redirect("/login?error=invalid");
  }

  const supabase = createSupabaseServerClient(context);
  const origin = new URL(context.request.url).origin;

  const { error } = await supabase.auth.signInWithOtp({
    email,
    options: { emailRedirectTo: `${origin}/api/auth/callback` },
  });

  if (error) {
    console.error("signInWithOtp failed:", error);
    return context.redirect("/login?error=send");
  }

  return context.redirect("/login?sent=1");
};
import type { APIRoute } from "astro";
import { createSupabaseServerClient } from "../../../lib/supabase";

export const prerender = false;

export const GET: APIRoute = async (context) => {
  const code = new URL(context.request.url).searchParams.get("code");
  if (!code) return context.redirect("/login?error=missing_code");

  const supabase = createSupabaseServerClient(context);
  // Sets the session cookies via the adapter in src/lib/supabase.ts.
  const { error } = await supabase.auth.exchangeCodeForSession(code);

  if (error) {
    console.error("exchangeCodeForSession failed:", error);
    return context.redirect("/login?error=auth");
  }

  return context.redirect("/dashboard");
};

Gotcha: the magic link uses PKCE, the code that signs you in only works in the same browser that requested it, because a verifier cookie was set when you submitted the form. Request the link and click it in the same browser. (This also means it can’t be replayed from someone else’s machine, that’s the point.)


Step 7 — A protected page + sign-out

Protected — src/pages/dashboard.astro

---
const user = Astro.locals.user;
if (!user) return Astro.redirect("/login");
---

<html lang="en">
  <head><meta charset="utf-8" /><title>Dashboard</title></head>
  <body style="font-family:system-ui;max-width:32rem;margin:6rem auto;padding:0 1rem">
    <h1>You're in.</h1>
    <p>Signed in as <strong>{user.email}</strong>.</p>
    <form method="POST" action="/api/auth/signout">
      <button type="submit" style="padding:.5rem 1rem">Sign out</button>
    </form>
  </body>
</html>

Sign out — src/pages/api/auth/signout.ts

import type { APIRoute } from "astro";

export const prerender = false;

export const POST: APIRoute = async (context) => {
  await context.locals.supabase.auth.signOut(); // clears the session cookies
  return context.redirect("/");
};

Test it

npm run dev

Go to http://localhost:4321/dashboard, you’re bounced to /login. Enter your email, click the link in your inbox, and you land on the dashboard with your email shown. Refresh, still logged in (that’s the cookie session working). Sign out, back to square one.


Step 8 — Deploy

Swap the local Node adapter for your host’s. For Cloudflare Pages:

npx astro add cloudflare

Set adapter: cloudflare() in astro.config.mjs (keep output: "server"). Then:

  1. Add PUBLIC_SUPABASE_URL and PUBLIC_SUPABASE_ANON_KEY as environment variables in your host’s dashboard.
  2. In Supabase → Authentication → URL Configuration, add your production URLs:
    • Site URL: https://yourdomain.com
    • Redirect URL: https://yourdomain.com/api/auth/callback

Gotcha: forget the production redirect URL and the magic link will bounce users to localhost (or error). The redirect URL list must contain every environment you run in.


You now have

Real, cookie-based authentication: passwordless login, sessions that survive refreshes, and a protected page. This is the foundation every app with accounts needs.


Troubleshooting

  • Clicking the link logs me out / “auth” error → you opened the link in a different browser than you requested it from. PKCE verifier lives in a cookie; use the same browser.
  • Redirected to localhost in production → add your production URL to Supabase → Authentication → URL Configuration → Redirect URLs.
  • Astro.locals.user is always null → the page is being prerendered. Confirm output: "server" in astro.config.mjs, and that the middleware file is exactly src/middleware.ts.
  • No email arrives → Supabase’s built-in email has low rate limits and can be slow. Check spam; for production, configure a custom SMTP provider in Supabase Auth settings.
  • “Invalid Refresh Token” after a while → expected when a session expires; the user just logs in again. getUser() in the middleware handles the revalidation.