Sign in with Supabase
Add passwordless email sign-in using Supabase's server-side SDK. The browser submits a native form; your server handles cookies and verifies the user.
Configure the provider
In your Supabase project:
- Enable passwordless email sign-in.
- Set the Site URL to your app's canonical origin and add its exact
/auth/callbackURL to Redirect URLs. Preserve entries used by other apps. - Keep the email template's
ConfirmationURLlink so the callback receives an authorization code.
Use the publishable/anon key, never a service-role key. For local work, use http://127.0.0.1:3000 consistently; don't mix it with localhost. Public apps need a fixed HTTPS origin and SMTP configured for real users.
Run the example
Set APP_ORIGIN, SUPABASE_URL, and SUPABASE_PUBLISHABLE_KEY in your shell or secret manager. Don't put values in source control. Then, from the repository root:
npm run build:packages
node --import tsx docs/samples/supabase-start.tsOpen http://127.0.0.1:3000/login. Request a link and open the newest email in the same browser. PKCE binds the link to that browser; forwarding it elsewhere should fail.
Entry point and environment checks
import { authApp } from "./supabase.js";
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Set ${name} before starting`);
return value;
}
authApp({
origin: required("APP_ORIGIN"),
supabaseUrl: required("SUPABASE_URL"),
publishableKey: required("SUPABASE_PUBLISHABLE_KEY"),
}).listen(Number(process.env.PORT ?? 3000));Missing configuration fails at startup. Environment files aren't loaded automatically; use Node's --env-file option if needed.
How the request flow works
The app creates an SDK client per handler. A login request saves a PKCE verifier in an HttpOnly cookie. The email returns a code; the callback exchanges that code using the verifier and writes session cookies. Protected handlers call getUser() to verify identity, not just getSession().
The SDK refreshes expired sessions and updates cookies before the response is sent. Your app still checks resource ownership and permissions using the verified user ID. Don't share one client across users or cache authenticated responses.
Complete auth implementation
import { createApp, HttpError, type Request, type Response } from "@portablesheep/brevity";
import { html, renderPage } from "@portablesheep/brevity-web";
import { createServerClient } from "@supabase/ssr";
import { parse, serialize } from "cookie";
export type AuthConfig = {
origin: string;
supabaseUrl: string;
publishableKey: string;
fetch?: typeof fetch;
};
export function authApp(config: AuthConfig) {
const origin = new URL(config.origin);
if (origin.origin !== config.origin || !["https:", "http:"].includes(origin.protocol)) {
throw new Error("Configure an exact HTTP(S) public origin, without a trailing slash");
}
if (origin.protocol === "http:" && !["localhost", "127.0.0.1"].includes(origin.hostname)) {
throw new Error("Use HTTPS outside local development");
}
const secure = origin.protocol === "https:";
const cookieName = secure ? "__Host-brevity-auth" : "brevity-auth";
function client(req: Request, res: Response) {
const cookies = new Map(
Object.entries(parse(req.headers.cookie ?? "")).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
return createServerClient(config.supabaseUrl, config.publishableKey, {
cookieOptions: { name: cookieName, path: "/", sameSite: "lax", httpOnly: true, secure },
global: config.fetch ? { fetch: config.fetch } : undefined,
cookies: {
getAll: () => [...cookies].map(([name, value]) => ({ name, value })),
setAll(updates) {
const existing = res.raw.getHeader("set-cookie");
const headers =
existing === undefined ? [] : Array.isArray(existing) ? existing : [String(existing)];
for (const { name, value, options } of updates) {
cookies.set(name, value);
headers.push(
serialize(name, value, {
...options,
path: "/",
sameSite: "lax",
httpOnly: true,
secure,
}),
);
}
res.raw.setHeader("set-cookie", headers);
},
},
});
}
const app = createApp({ maxBodyBytes: 4096 });
app.use(async (req, res, next) => {
res.header("referrer-policy", "same-origin");
res.header("cache-control", "private, no-store");
if (
["POST", "PUT", "PATCH", "DELETE"].includes(req.method) &&
req.headers.origin !== config.origin
) {
throw new HttpError(403, "Submit from this site");
}
return next(req, res);
});
app.router.get("/login", (_req, res) =>
renderPage(
res,
() =>
html` <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Sign in</title>
</head>
<body>
<h1>Sign in</h1>
<form method="post" action="/login">
<label for="email">Email</label
><input id="email" name="email" type="email" required />
<button>Send sign-in link</button>
</form>
</body>
</html>`,
{},
),
);
app.router.post("/login", async (req, res) => {
const email = req.form?.get("email")?.trim();
if (
!email ||
req.form?.getAll("email").length !== 1 ||
email.length > 254 ||
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
) {
throw new HttpError(422, "Enter one valid email address");
}
const supabase = client(req, res);
const { error } = await supabase.auth.signInWithOtp({
email,
options: { emailRedirectTo: `${config.origin}/auth/callback` },
});
if (error) {
if (error.status === 429) throw new HttpError(429, "Wait before requesting another link");
console.error("[auth] email request failed", error.code);
throw new HttpError(502, "Could not send a sign-in link");
}
res.send("Check your email. Open the link in this browser.");
});
app.router.get("/auth/callback", async (req, res) => {
const codes = new URL(req.url, config.origin).searchParams.getAll("code");
const cookies = parse(req.headers.cookie ?? "");
const hasVerifier = Object.keys(cookies).some(
(name) =>
name === `${cookieName}-code-verifier` || name.startsWith(`${cookieName}-code-verifier.`),
);
if (codes.length !== 1 || !codes[0] || !hasVerifier) {
throw new HttpError(400, "Open the newest link in the browser that requested it");
}
const { error } = await client(req, res).auth.exchangeCodeForSession(codes[0]);
if (error) {
console.error("[auth] code exchange failed", error.code);
throw new HttpError(400, "This sign-in link is invalid or expired; request a new one");
}
res.redirect("/account", 303);
});
app.router.get("/account", async (req, res) => {
// getUser validates with Auth and refreshes when needed; getSession alone is not authorization.
const { data, error } = await client(req, res).auth.getUser();
if (error && (!error.status || error.status >= 500)) {
console.error("[auth] verification failed", error.code);
throw new HttpError(502, "Identity provider unavailable");
}
if (error || !data.user) throw new HttpError(401, "Sign in first");
renderPage(
res,
() =>
html`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Account</title>
</head>
<body>
<h1>Account</h1>
<p>${data.user.email}</p>
<form method="post" action="/logout"><button>Sign out</button></form>
</body>
</html>`,
{},
);
});
app.router.post("/logout", async (req, res) => {
const { error } = await client(req, res).auth.signOut({ scope: "local" });
if (error) {
console.error("[auth] logout failed", error.code);
throw new HttpError(502, "Could not sign out");
}
res.redirect("/login", 303);
});
return app;
}Cookies and form security
Cookies are HttpOnly, Path /, SameSite Lax, and Secure on HTTPS. HTTPS names use __Host- without a Domain attribute. The SDK supports chunked cookies; append separate Set-Cookie headers rather than combining them with commas.
This is a server-only session: a browser Supabase SDK can't read these HttpOnly cookies. Mutations, including login/logout, use POST and require the configured exact Origin. Keep Referrer-Policy: same-origin; changing it to no-referrer can remove the Origin needed by browser forms. Don't derive trusted redirects from arbitrary Host/forwarded headers.
The callback accepts no user-controlled destination. Expired or invalid codes produce a visible error, without logging codes or tokens. Asking for another link can replace the verifier, so use the newest link.
Logout clears this browser's session using local scope. It doesn't instantly revoke every already-issued JWT; account for that in sensitive operations.
Handle email limits
Supabase's built-in sender has restrictive email rate limits. Check the project's current limits and configure SMTP before public use. This example shows 429 as an error instead of pretending an email was sent.
Add rate limiting and bot protection for a public login form. Don't retry email requests in a loop or log addresses, codes, or tokens.
Check your setup
Try sign-in in the original browser and rejection in a different browser. Then verify expired/reused links, logout, cross-site POST rejection, email failures, and 429 handling. Test refresh with a short local token lifetime before changing any hosted settings.
The recipe tests use Supabase SSR 0.7.0, JS 2.57.4, and cookie 1.0.2 against stubbed Auth HTTP. They check PKCE, cookies, Origin, and error handling, not email delivery or successful live refresh. Run the hosted checks separately with your own provider configuration.