Start
Sign in with React.
Make a React app with Vite, point it at your instance, and sign in. It takes about ten minutes.
Using Next.js? The Next.js quickstart has a ready app to download.
Before you start
- Node 20.19 or newer.
- Your instance's address, such as
https://your-instance.signetauth.dev.
How the pieces fit
You work with two addresses.
- Your app's address, such as
http://localhost:3000. People open your app here. Your instance must trust this address. - Your instance's auth URL: its address plus
/api/auth, such ashttps://your-instance.signetauth.dev/api/auth. Only your server uses it.
The browser talks only to your app. Your app passes each /api/auth call on to the auth URL, so the sign-in cookie belongs to your app. To check who is signed in, your server sends the visitor's cookie to the auth URL and asks for the session.
Why not call the instance from the browser? It is on another address, and it does not answer requests from other addresses, so the browser blocks them.
Build the app
Make a new React app and add the stock
better-authclient.terminalnpm create vite@latest my-app -- --template react-ts --no-interactive cd my-app npm install npm install better-auth@1.6.23Make a file named
.env.local. SetSIGNET_URLto your instance's address plus/api/auth..env.localSIGNET_URL=https://your-instance.signetauth.dev/api/authReplace
vite.config.ts. It runs the app on port 3000 and passes/api/authcalls to your instance. It also adds/api/me, a session check that runs on the server.vite.config.tsimport { defineConfig, loadEnv, type Plugin } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig(({ mode }) => { // Your instance's auth URL, from .env.local. It has no VITE_ prefix, // so it stays in this server and is never sent to the browser. const signetUrl = (loadEnv(mode, process.cwd(), "").SIGNET_URL ?? "").replace(/\/+$/, ""); if (!signetUrl.endsWith("/api/auth")) { throw new Error( "Set SIGNET_URL in .env.local to your instance address plus /api/auth, " + "for example SIGNET_URL=https://your-instance.signetauth.dev/api/auth. Then run npm run dev again.", ); } const instance = new URL(signetUrl); return { plugins: [react(), sessionCheck(signetUrl)], server: { port: 3000, strictPort: true, proxy: { // The browser calls /api/auth on this app. Vite passes each call on to // your instance, and removes the cookie's Domain so it stays on this app. "/api/auth": { target: instance.origin, changeOrigin: true, cookieDomainRewrite: "", rewrite: (path) => instance.pathname + path.slice("/api/auth".length), }, }, }, }; }); // The server-side session check. GET /api/me sends the visitor's cookies to // Signet and answers with their email, or 401 when nobody is signed in. // It runs in Vite's server, never in the browser. In production, put the // same check in your own API. function sessionCheck(signetUrl: string): Plugin { return { name: "signet-session-check", configureServer(server) { server.middlewares.use("/api/me", async (req, res) => { res.setHeader("content-type", "application/json"); const answer = await fetch(`${signetUrl}/get-session`, { headers: { cookie: req.headers.cookie ?? "" }, }).catch(() => null); if (!answer?.ok) { res.statusCode = 502; res.end(JSON.stringify({ error: "Could not reach Signet. Check SIGNET_URL in .env.local." })); return; } const session = (await answer.json()) as { user: { email: string } } | null; if (!session) { res.statusCode = 401; res.end(JSON.stringify({ error: "Not signed in." })); return; } res.end(JSON.stringify({ email: session.user.email })); }); }, }; }Make
src/auth-client.ts. The client has nobaseURL, so it calls/api/authon your app.src/auth-client.tsimport { createAuthClient } from "better-auth/react"; // No baseURL: the client calls /api/auth on this app, and Vite passes it on. export const authClient = createAuthClient();Replace
src/App.tsxwith a form that creates an account, signs in and signs out.If your instance asks for Cloudflare's person check, the form shows it and sends itself when the check is done.
src/App.tsximport { useEffect, useRef, useState } from "react"; import { authClient } from "./auth-client"; type Mode = "sign-up" | "sign-in"; type Refusal = { code?: string; message?: string; details?: { widget?: { provider?: string; siteKey?: string } }; }; declare global { interface Window { turnstile?: { render: (host: HTMLElement, options: Record<string, unknown>) => string; remove: (id: string) => void; }; } } // Cloudflare's person check. Signet asks for it on sign-up when the instance // has it turned on, and sends the site key in its answer. function loadTurnstile(): Promise<void> { if (window.turnstile) return Promise.resolve(); return new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; script.onload = () => resolve(); script.onerror = () => reject(new Error("Could not load Cloudflare's person check.")); document.head.appendChild(script); }); } export default function App() { const { data: session, isPending, refetch } = authClient.useSession(); const [mode, setMode] = useState<Mode>("sign-up"); const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [problem, setProblem] = useState(""); const [notice, setNotice] = useState(""); const [siteKey, setSiteKey] = useState(""); const [serverSays, setServerSays] = useState(""); const widgetHost = useRef<HTMLDivElement>(null); async function submit(captcha?: string) { setProblem(""); setNotice(""); const fetchOptions = captcha ? { headers: { "x-captcha-response": captcha } } : undefined; const result = mode === "sign-up" ? await authClient.signUp.email({ name, email, password, callbackURL: `${location.origin}/`, fetchOptions }) : await authClient.signIn.email({ email, password, fetchOptions }); if (result.error) { const refusal = result.error as Refusal; const widget = refusal.details?.widget; if (widget?.provider === "cloudflare-turnstile" && widget.siteKey && !captcha) { setSiteKey(widget.siteKey); return; } setProblem(`${refusal.message ?? "Signet refused the request."} (${refusal.code ?? result.error.status})`); return; } setSiteKey(""); if (!result.data?.token) { setNotice(`Signet sent a confirmation link to ${email}. Open it, then sign in.`); return; } await refetch(); } // Draw the person check when Signet asks for it. A finished check sends the form again. useEffect(() => { if (!siteKey || !widgetHost.current) return; let id: string | undefined; loadTurnstile() .then(() => { if (widgetHost.current && window.turnstile) { id = window.turnstile.render(widgetHost.current, { sitekey: siteKey, callback: (token: string) => void submit(token), }); } }) .catch((error: Error) => setProblem(error.message)); return () => { if (id && window.turnstile) window.turnstile.remove(id); }; // submit reads the latest form values when the check finishes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [siteKey]); async function askServer() { const answer = await fetch("/api/me"); const body = await answer.json(); setServerSays(answer.ok ? `Your server sees ${body.email}.` : `Your server says: ${body.error}`); } if (isPending) return <p>Checking whether you are signed in…</p>; if (session) { return ( <main> <h1>You are signed in</h1> <p>Signed in as {session.user.email}. Refresh the page: you stay signed in.</p> <button onClick={askServer}>Ask the server who I am</button> <button onClick={async () => { await authClient.signOut(); setServerSays(""); }}>Sign out</button> {serverSays && <p role="status">{serverSays}</p>} </main> ); } return ( <main> <h1>{mode === "sign-up" ? "Create an account" : "Sign in"}</h1> <form onSubmit={(event) => { event.preventDefault(); void submit(); }}> {mode === "sign-up" && ( <label>Name <input required value={name} onChange={(e) => setName(e.target.value)} /></label> )} <label>Email <input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} /></label> <label>Password <input type="password" required minLength={8} value={password} onChange={(e) => setPassword(e.target.value)} /></label> <button type="submit">{mode === "sign-up" ? "Create account" : "Sign in"}</button> </form> <button onClick={() => setMode(mode === "sign-up" ? "sign-in" : "sign-up")}> {mode === "sign-up" ? "I have an account" : "I need an account"} </button> {siteKey && <div ref={widgetHost} />} {problem && <p role="alert">{problem}</p>} {notice && <p role="status">{notice}</p>} <button onClick={askServer}>Ask the server who I am</button> {serverSays && <p role="status">{serverSays}</p>} </main> ); }Make sure your instance trusts
http://localhost:3000.On a hosted instance, the console's Setup page lists the allowed app addresses. If yours is not there, email support@signetauth.com. On your own server, add it to
[server].trusted_originsinsignet.toml.signet.toml[server] trusted_origins = ["http://localhost:3000"]Start the app with
npm run dev, then openhttp://localhost:3000.- Fill in a name, email and password, and press Create account. Refresh the page: you stay signed in.
- Press Ask the server who I am. Your server sends your cookie to Signet and shows your email.
- Press Sign out, then press Ask the server who I am again. Your server says you are not signed in.
If something goes wrong
- Vite stops with a message about
SIGNET_URL. Put the line from step 2 in.env.local, then runnpm run devagain. - Every sign-up and sign-in is refused with
INVALID_ORIGIN. Your instance does not trusthttp://localhost:3000yet. Step 6 says where to add it. - Port 3000 is in use. Stop the other app. Your instance trusts one exact address, so the port must stay 3000.
- The form says a confirmation link was sent. Your instance wants the email confirmed first. Open the link, then sign in.
- Something else. Troubleshooting your first sign-in starts from what you see and gives the fix.
Going live
- Vite passes
/api/authon only while you develop. In production, have the server that serves your app pass/api/authto your auth URL in the same way. Keep the browser'sOriginheader, and removeDomainfrom each cookie. - Put the
/api/mecheck in your own API. The Express example is the same check as middleware. - Ask for your live app's address to be trusted, as in step 6.