Menu

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 as https://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

  1. Make a new React app and add the stock better-auth client.

    terminal
    npm create vite@latest my-app -- --template react-ts --no-interactive
    cd my-app
    npm install
    npm install better-auth@1.6.23
  2. Make a file named .env.local. Set SIGNET_URL to your instance's address plus /api/auth.

    .env.local
    SIGNET_URL=https://your-instance.signetauth.dev/api/auth
  3. Replace vite.config.ts. It runs the app on port 3000 and passes /api/auth calls to your instance. It also adds /api/me, a session check that runs on the server.

    vite.config.ts
    import { 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 }));
          });
        },
      };
    }
    
  4. Make src/auth-client.ts. The client has no baseURL, so it calls /api/auth on your app.

    src/auth-client.ts
    import { createAuthClient } from "better-auth/react";
    
    // No baseURL: the client calls /api/auth on this app, and Vite passes it on.
    export const authClient = createAuthClient();
    
  5. Replace src/App.tsx with 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.tsx
    import { 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>
      );
    }
    
  6. 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_origins in signet.toml.

    signet.toml
    [server]
    trusted_origins = ["http://localhost:3000"]
  7. Start the app with npm run dev, then open http://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 run npm run dev again.
  • Every sign-up and sign-in is refused with INVALID_ORIGIN. Your instance does not trust http://localhost:3000 yet. 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/auth on only while you develop. In production, have the server that serves your app pass /api/auth to your auth URL in the same way. Keep the browser's Origin header, and remove Domain from each cookie.
  • Put the /api/me check 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.

Enter to open · Esc to close