Hedronite · Frontend Dev · TypeScript · 2026-10-29

Sign-in for a Vite React app with Better Auth a Hono API on Bun, an HttpOnly session cookie, and an origin-checked gate

Mount Better Auth in a Hono API on Bun with SQLite, look inside the signed HttpOnly session cookie, gate every route on a real session, refuse untrusted origins, and sign in from React through Vite's proxy.

Lesson Class: Dev (TypeScript front end · authentication at the API edge)
Tested with: better-auth 1.7.14 · hono 4.13.14 · vite 8.3.5 · react 19.3.1 · typescript 7.0.2 · bun 1.4.4 (macOS) and 1.4.2 (Linux) · Chrome 156 · macOS + Linux
Lab: notebox · bun test (8 tests) · curl through the proxy · probes · Bun.WebView browser check under StrictMode
Source: Better Auth docs (Hono, SQLite, Database, Sessions, Cookies, Security, Email & Password, Client, API) · MDN Using HTTP cookies · Vite server options · Hono docs · Bun docs · JavaScript: The Definitive Guide §15.12.2
The Cookie Only Names a Row
The session cookie is a signed token for a server-side session row, so editing it fails and signing out kills it.
One Gate at the Edge
A single middleware asks Better Auth for the session before any route runs, so an anonymous call never reaches a handler.
Trust Is an Origin List
Sign-in from an untrusted Origin gets 403, and tests must switch the check on because the default skips it under NODE_ENV=test.
Hand out a name, keep the meaning, and let the server decide who is home.

Versions: better-auth 1.7.14, Hono 4.13.14, Vite 8.3.5, @vitejs/plugin-react 6.1.2, React 19.3.1, TypeScript 7.0.2, Bun 1.4.4 (macOS 27.0.1, arm64) and 1.4.2 (Linux x64) with SQLite through bun:sqlite; the browser check uses Bun.WebView (experimental in Bun's docs: WebKit on macOS, Chrome 156 on Linux).

The problem

"notebox" is a small app built with Vite and React, plus an API that serves notes. The notes are private, so the API must hand them only to the person who owns them. The team needs accounts (an email and a password) and a way for the browser to stay signed in across page reloads. Checking who someone is has a name: authentication. The server also needs one firm rule: refuse any request that does not carry a valid sign-in. That rule has to cover requests that a different website tricks the user's browser into sending, because a browser may attach stored credentials to a request no matter which page started it.

Writing this by hand means four separate jobs, and each is easy to get subtly wrong. You must hash passwords, so a stolen database does not reveal them. You must store sessions (the server's memory that "this browser is signed in as Ada"). You must set cookie flags correctly. And you must defend against cross-site requests. A mistake in any one of them gives you an app that appears to work until someone looks closely. Here a library does all four jobs, and real output shows what each piece does and what breaks when one is missing.

The idea in plain words

Better Auth is a TypeScript library, not a hosted service, so you put it inside your own API process and let it own every URL under /api/auth/. It stores users and hashed passwords (one-way scrambled copies, so the real password is never saved) in your database. On sign-in it creates a session record, which is the server-side note that a particular sign-in is still valid. It hands the browser a session cookie (a small named value the browser stores and sends back automatically). That cookie is signed, meaning it carries a short code that only the server can produce, so any edit is detectable. It is marked HttpOnly, which hides it from page JavaScript, and it only names the session record. Every other API route runs through a check called "the gate": a function that asks Better Auth for the current session before any route code runs, and answers 401 (the HTTP status for "not authenticated") if there is none. The browser reaches the API through the page's own address (Vite's dev proxy, a feature that forwards some URLs to another server), so the cookie is first-party and the page's JavaScript never touches it.

Concept 1: Better Auth inside a Hono app on Bun, with SQLite and a migration

Better Auth is a TypeScript library that runs in your process and keeps its data in your database. Per the docs, it needs four tables: user, session, account and verification. The account table is where the credential (the password hash) lives. You choose the database. Here that is SQLite through Bun's built-in bun:sqlite module, which the Better Auth SQLite docs show you can pass straight in.

Why Hono? Hono and Better Auth both use the Web Standard Request and Response objects, the same types you meet when you call fetch() in the browser. Because both sides speak them, Hono can hand Better Auth the raw request and send back whatever Response it returns, with no adapter in between. The docs state this directly, and the result in this lab is one app.all line.

The project starts with the configuration files.

{
  "name": "notebox",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite --host 127.0.0.1 --strictPort",
    "api": "bun server/main.ts",
    "migrate": "bun server/migrate.ts",
    "test": "bun test",
    "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.server.json",
    "build": "vite build"
  },
  "dependencies": {
    "better-auth": "1.7.14",
    "hono": "4.13.14",
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@types/bun": "1.4.4",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "@vitejs/plugin-react": "6.1.2",
    "typescript": "7.0.2",
    "vite": "8.3.5"
  }
}

Every version is an exact pin, with no ^ ranges, so the lesson's output stays reproducible. The scripts name the jobs: api starts the Bun server, migrate creates the tables, dev starts Vite on 127.0.0.1 with --strictPort, which fixes the port so the page's address cannot drift. typecheck runs the TypeScript compiler twice, once per config file, because browser code and server code need different settings. React and Vite are only used by the browser half. Hono and Better Auth are used by the server half.

[install]
exact = true

[test]
# The tests exercise the API server, so they live next to it.
root = "./server"

exact = true tells bun install to record exact versions instead of ranges, matching the pins above. The [test] section points bun test at the server folder, because that is where the tests live. Without it Bun would search from the project root.

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "isolatedModules": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["vite/client"]
  },
  "include": ["src"]
}
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["bun"]
  },
  "include": ["server"]
}

These two TypeScript configs split the project in half. The first covers src, the React code, and turns on the browser's DOM types and the JSX setting. The second covers server and loads Bun's types instead, so Bun.serve and bun:sqlite type-check while browser-only names like document do not exist there. Both set noEmit, since TypeScript only checks here and Vite and Bun run the code themselves.

Next is the auth instance itself. It is a factory function rather than a single exported object so the tests can build one on an in-memory database while the real server builds one on a file.

import { betterAuth } from "better-auth";
import { Database } from "bun:sqlite";

// The browser loads the app from Vite on this origin and reaches the API
// through Vite's proxy, so this is the only origin we trust.
export const APP_ORIGIN = "http://127.0.0.1:5173";

export function createAuth(file: string) {
  return betterAuth({
    database: new Database(file),
    baseURL: APP_ORIGIN,
    secret: process.env.BETTER_AUTH_SECRET,
    trustedOrigins: [APP_ORIGIN],
    emailAndPassword: { enabled: true },
    // Better Auth skips its Origin check when NODE_ENV is "test", and
    // bun test sets exactly that. Say false so tests see production rules.
    advanced: { disableOriginCheck: false },
  });
}

export type Auth = ReturnType<typeof createAuth>;

Walk through the options. database receives a bun:sqlite handle. baseURL is the public address of the app, and it is the page's address (port 5173), not the API's port, because the browser only ever talks to Vite. secret is a secret: a random value known only to the server, which Better Auth uses for encryption and hashing, including signing the cookie. The docs require at least 32 characters with high entropy and suggest openssl rand -base64 32. We read it from the environment so it never lands in the repository. trustedOrigins lists the origins allowed to make state-changing requests (Concept 3 defines origin). emailAndPassword: { enabled: true } turns on the sign-up and sign-in endpoints. The advanced.disableOriginCheck: false line looks redundant, and Concept 3 explains why it is not.

A fresh database has no tables, so something has to create them. A migration is a script that creates or changes tables so the database matches what the code expects. The CLI form is bun x auth@latest migrate. The docs also describe a programmatic form, which works wherever the CLI is awkward and, as you will see, lets tests migrate an in-memory database.

import { getMigrations } from "better-auth/db/migration";
import { createAuth } from "./auth";

// Creates the user, session, account and verification tables if missing.
const auth = createAuth(process.env.DB_FILE ?? "notebox.sqlite");
const { toBeCreated, toBeAdded, runMigrations } = await getMigrations(auth.options);
console.log("tables to create:", toBeCreated.map((t) => t.table).join(", ") || "none");
console.log("tables to alter:", toBeAdded.map((t) => t.table).join(", ") || "none");
await runMigrations();
console.log("migrations done");

getMigrations(auth.options) compares the database with the tables Better Auth needs and returns two lists plus a runMigrations function. The script prints what it is about to create, then runs it. Printing first lets you see a plan before it executes. The docs say getMigrations only works with the built-in Kysely adapter, which covers SQLite. The DB_FILE environment variable chooses the database file, so one script serves both the dev file and the throwaway file the lab uses.

import { createAuth } from "./auth";
import { createApp } from "./app";

if (!process.env.BETTER_AUTH_SECRET) {
  console.error("Set BETTER_AUTH_SECRET (32+ random characters) first.");
  process.exit(1);
}

const auth = createAuth(process.env.DB_FILE ?? "notebox.sqlite");
const app = createApp(auth);
const server = Bun.serve({ hostname: "127.0.0.1", port: 8787, fetch: app.fetch });
console.log(`notebox API on ${server.url}`);

main.ts refuses to start without the secret. Failing loudly at launch is better than running with a missing signing key. It then builds the auth instance and the Hono app, and passes app.fetch to Bun.serve. That works because a Hono app's fetch takes a Web Standard Request and returns a Response, the shape Bun's server expects. The server listens on 127.0.0.1:8787, which only the Vite proxy needs to reach. The file server/app.ts that it imports appears in Concept 3.

Here is the migration running against the lab's real database file, copied from the Linux run:

$ bun server/migrate.ts
tables to create: user, session, account, verification
tables to alter: none
migrations done

The four tables match the docs' list of required core tables. "Tables to alter: none" means a brand-new database needs only creation. On a later run the first line would say none, because the tables already exist.

The probe script below captures the failure you hit when you skip this step. It also holds three more diagnostics that later sections use.

// Diagnostics shown in the lesson. Usage: bun probes.ts
import { getMigrations } from "better-auth/db/migration";
import { APP_ORIGIN, createAuth } from "./server/auth";
import { createApp } from "./server/app";

process.env.BETTER_AUTH_SECRET ??= "probe-secret-probe-secret-probe-secret-01";
const auth = createAuth(":memory:");
const app = createApp(auth);
const post = (path: string, body: object, headers: Record<string, string> = {}) =>
  app.request(`${APP_ORIGIN}${path}`, {
    method: "POST",
    headers: { "content-type": "application/json", ...headers },
    body: JSON.stringify(body),
  });
const ada = { name: "Ada", email: "[email protected]", password: "correct horse battery" };

// 1. Forgot to migrate: the tables do not exist yet.
let res = await post("/api/auth/sign-up/email", ada, { origin: APP_ORIGIN });
console.log("1. sign-up before migrating:", res.status, await res.text());

await (await getMigrations(auth.options)).runMigrations();
res = await post("/api/auth/sign-up/email", ada, { origin: APP_ORIGIN });
console.log("   sign-up after migrating: ", res.status);

// 2. No Origin header at all (curl, a server, a script): no browser to protect.
res = await post("/api/auth/sign-in/email", { email: ada.email, password: ada.password });
console.log("2. sign-in with no Origin:  ", res.status, res.headers.getSetCookie().length, "cookie(s) set");

// 3. A short password is refused by the server, whatever the form allowed.
res = await post("/api/auth/sign-up/email", { ...ada, email: "[email protected]", password: "short" }, { origin: APP_ORIGIN });
console.log("3. 5-character password:   ", res.status, await res.text());

// 4. Where the password went: the account table holds a scrypt hash, not the password.
const db = auth.options.database as import("bun:sqlite").Database;
const acct = db.query(`select providerId, password from account limit 1`).get() as { providerId: string; password: string };
console.log("4. account row:             ", acct.providerId, acct.password.split(":").map((p) => `${p.length} hex chars`).join(" : "));

The script builds an in-memory database and the same Hono app the server uses, then talks to it with app.request. Hono documents that method for testing: it takes a path or URL plus a request init and returns the Response without a running server. Probe 1 signs up before migrating, then migrates and tries again. Probes 2 to 4 reuse the same app, and Concepts 2 to 4 return to them. The post helper always sends JSON, which matters for the cross-site defenses in Concept 3.

Probe 1 produced this on Linux (macOS printed the same four result lines for the whole script). The stack trace between the log block and the result lines is trimmed.

2026-10-29T20:34:50.768Z ERROR [Better Auth]: Database schema mismatch

  Missing tables
    user, session, account, verification

  help: Run `npx auth migrate` to add the missing tables and columns.
1. sign-up before migrating: 500 Internal Server Error
   sign-up after migrating:  200

Symptom: sign-up returns 500 Internal Server Error, and the server log says Database schema mismatch with Missing tables user, session, account, verification. Cause: nobody ran the migration, so the database has no tables for Better Auth to write to. Fix: run the migration (bun run migrate, or the npx auth migrate command the log suggests) before the first request. The second line shows the same sign-up returning 200 once the tables exist.

The same error appears in a place that may confuse you. The top of bun test output prints one ERROR line even though the suite passes:

bun test v1.4.2 (744846f84)

server/auth.test.ts:
2026-10-29T20:35:28.656Z ERROR [Better Auth]: Database schema mismatch

  Missing tables
    user, session, account, verification

  help: Run `npx auth migrate` to add the missing tables and columns.

The test file (shown in Concept 2) builds the auth instance when it loads, and its beforeAll hook runs the migration afterwards. The message appears before the migration runs, and the suite then passes all 8 tests. Treat it as a message about that moment, not as a failure: the 8 passing tests all run after the migration.

Concept 2: what the session cookie is, and what it is not

A cookie is a small piece of named data that the browser stores and sends back to the same site automatically. It was designed for server-side programming: the server sets it in a Set-Cookie response header, and the browser attaches it to later requests in a Cookie header. Cookies are transient by default and vanish when the browsing session ends. To make one last, the server sets Max-Age, a lifetime in seconds. A session cookie in our sense is the cookie that says which signed-in session the browser belongs to.

Three more attributes matter here. HttpOnly stops JavaScript from reading the cookie through document.cookie, so it only reaches the server. MDN says session cookies should have it, because it helps mitigate XSS (cross-site scripting, where an attacker's script runs inside your page and could otherwise read cookies and send them away). SameSite tells the browser when to send the cookie on cross-site requests, with the values Strict, Lax and None. Secure limits the cookie to encrypted HTTPS requests, and MDN adds that insecure http: sites cannot set Secure cookies at all.

Why does it matter what is inside the cookie? If the cookie held your identity directly, anyone who edited it could become someone else. Better Auth's docs say the cookie is an opaque, secret-signed session identifier: it names a session record on the server and proves, via the signature, that the server issued it. The signature is a short code computed from the value and the secret, so a changed value no longer matches. The actual data stays in the database.

The test file proves each of these claims against a real instance. Read it as the specification for this section.

import { beforeAll, describe, expect, test } from "bun:test";
import { getMigrations } from "better-auth/db/migration";
import { APP_ORIGIN, createAuth } from "./auth";
import { createApp } from "./app";

process.env.BETTER_AUTH_SECRET ??= "test-secret-test-secret-test-secret-0123";

const auth = createAuth(":memory:");
const app = createApp(auth);
const db = auth.options.database as import("bun:sqlite").Database;

// Requests look like the browser's: same origin as the app, JSON bodies.
function call(path: string, init: RequestInit = {}) {
  const headers = new Headers(init.headers);
  if (!headers.has("origin")) headers.set("origin", APP_ORIGIN);
  if (init.body) headers.set("content-type", "application/json");
  return app.request(`${APP_ORIGIN}${path}`, { ...init, headers });
}

const ada = { name: "Ada", email: "[email protected]", password: "correct horse battery" };
let cookie = ""; // "better-auth.session_token=<value>", as the browser would send it

beforeAll(async () => {
  const { toBeCreated, runMigrations } = await getMigrations(auth.options);
  expect(toBeCreated.map((t) => t.table).sort()).toEqual(["account", "session", "user", "verification"]);
  await runMigrations();
});

describe("the session gate", () => {
  test("no cookie means 401 from the protected route", async () => {
    const res = await call("/api/notes");
    expect(res.status).toBe(401);
    expect(await res.json()).toEqual({ error: "sign in first" });
  });

  test("sign-up sets an HttpOnly, SameSite=Lax session cookie", async () => {
    const res = await call("/api/auth/sign-up/email", { method: "POST", body: JSON.stringify(ada) });
    expect(res.status).toBe(200);
    const setCookie = res.headers.getSetCookie();
    const session = setCookie.find((c) => c.startsWith("better-auth.session_token="))!;
    console.log("Set-Cookie:", session.replace(/=[^;]+/, "=<token>.<signature>"));
    expect(session).toContain("HttpOnly");
    expect(session).toContain("SameSite=Lax");
    expect(session).not.toContain("Secure"); // http:// base URL, so no Secure flag
    cookie = session.split(";")[0];
  });

  test("the cookie opens the protected route", async () => {
    const res = await call("/api/notes", { headers: { cookie } });
    expect(res.status).toBe(200);
    const body = (await res.json()) as { owner: string };
    expect(body.owner).toBe("[email protected]");
  });

  test("the cookie value is the session row's token plus a signature", () => {
    const value = decodeURIComponent(cookie.split("=")[1]);
    const [token, signature] = value.split(".");
    const row = db.query("select token, userId from session").get() as { token: string; userId: string };
    expect(row.token).toBe(token);
    expect(signature.length).toBeGreaterThan(20);
    console.log(`session row token matches cookie; signature is ${signature.length} chars`);
  });

  test("a tampered signature is treated as no session", async () => {
    const forged = cookie.slice(0, -3) + (cookie.endsWith("AAA") ? "BBB" : "AAA");
    const res = await call("/api/notes", { headers: { cookie: forged } });
    expect(res.status).toBe(401);
  });
});

describe("sign-in and sign-out", () => {
  test("a wrong password is a 401 with a code, not a session", async () => {
    const res = await call("/api/auth/sign-in/email", {
      method: "POST",
      body: JSON.stringify({ email: ada.email, password: "wrong password!" }),
    });
    const body = await res.json();
    console.log("wrong password:", res.status, JSON.stringify(body));
    expect(res.status).toBe(401);
    expect(res.headers.getSetCookie()).toEqual([]);
  });

  test("sign-in from an untrusted origin is refused", async () => {
    const res = await call("/api/auth/sign-in/email", {
      method: "POST",
      headers: { origin: "http://evil.example" },
      body: JSON.stringify({ email: ada.email, password: ada.password }),
    });
    const body = await res.json();
    console.log("untrusted origin:", res.status, JSON.stringify(body));
    expect(res.status).toBe(403);
    expect(res.headers.getSetCookie()).toEqual([]);
  });

  test("sign-out deletes the session row, so the old cookie stops working", async () => {
    const out = await call("/api/auth/sign-out", { method: "POST", headers: { cookie } });
    expect(out.status).toBe(200);
    const left = db.query("select count(*) as n from session").get() as { n: number };
    expect(left.n).toBe(0);
    const res = await call("/api/notes", { headers: { cookie } });
    expect(res.status).toBe(401);
  });
});

The helper call builds requests the way a browser page would: it adds an Origin header equal to the app's origin unless a test overrides it, and a JSON content type when there is a body. The tests share one cookie variable, so they run in order as a story: unauthenticated request, sign-up, authenticated request, inspect the cookie against the database, tamper with it, then sign out. beforeAll runs the migration and first checks that exactly the four expected tables are about to be created. Two tests in the second block cover the origin check, which Concept 3 covers.

Here is the output for the passing run, from the Linux machine. The header and the schema error block, shown earlier, are trimmed.

(pass) the session gate > no cookie means 401 from the protected route [6.93ms]
Set-Cookie: better-auth.session_token=<token>.<signature>; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax
(pass) the session gate > sign-up sets an HttpOnly, SameSite=Lax session cookie [84.69ms]
(pass) the session gate > the cookie opens the protected route [2.26ms]
session row token matches cookie; signature is 44 chars
(pass) the session gate > the cookie value is the session row's token plus a signature [0.16ms]
(pass) the session gate > a tampered signature is treated as no session [0.28ms]
2026-10-29T20:35:28.809Z WARN [Better Auth]: Invalid password
wrong password: 401 {"message":"Invalid email or password","code":"INVALID_EMAIL_OR_PASSWORD"}
(pass) sign-in and sign-out > a wrong password is a 401 with a code, not a session [55.41ms]
2026-10-29T20:35:28.810Z ERROR [Better Auth]: Invalid origin: http://evil.example
untrusted origin: 403 {"message":"Invalid origin","code":"INVALID_ORIGIN"}
(pass) sign-in and sign-out > sign-in from an untrusted origin is refused [1.19ms]
(pass) sign-in and sign-out > sign-out deletes the session row, so the old cookie stops working [5.32ms]

 8 pass
 0 fail
 18 expect() calls
Ran 8 tests across 1 file. [255.00ms]

Take the Set-Cookie line one attribute at a time. The cookie name is better-auth.session_token, which matches the docs' default format of prefix better-auth, a dot, then the cookie name. The value is <token>.<signature>: the test confirmed the part before the dot equals the token column of the one session record in the database, and the signature is 44 characters. Max-Age=604800 is 60 × 60 × 24 × 7 seconds, which is 7 days, the documented default for how long a session lasts. Path=/ sends it to every URL on the origin. HttpOnly and SameSite=Lax are present, and Secure is absent. The test asserts that absence on purpose: the docs say cookies are secure only in production mode by default, and our base URL is plain http://, where browsers cannot set Secure cookies anyway. Whether Secure appears in production is what the docs state. This lab did not test it.

The tampered-signature test changes the last three characters of the cookie and gets a 401, so an edited cookie counts as no session at all. The sign-out test shows the other half of the design. Because the cookie only names a record, deleting the record (count goes to 0) makes the same cookie useless, even though the browser may still hold it. Cookie and record must agree, and the server controls the record.

The curl step of the lab shows the same facts from the outside. After sign-up, the cookie jar line printed by the script reads:

cookie jar: HttpOnly / better-auth.session_token=<token>.<signature>

That line is produced by an awk filter over the jar file curl wrote, and the word HttpOnly there comes from curl's own marker in the jar. The sign-up response body also contains a "token" field (you can see it in the curl table in the last section). The browser does not need it, because the cookie is what the browser sends on its own. There is no reason to copy that token into localStorage or any other place page JavaScript can read, since a script that can read it (an XSS attacker's, for example) could send it away. Keeping the credential in an HttpOnly cookie avoids that.

The password is not in the session at all. It was hashed with scrypt, which the docs name as the default algorithm. A password hash is the output of a one-way function: the server can check a login attempt against it but cannot recover the password. Probe 4 looked inside the account table:

4. account row:              credential 32 hex chars : 128 hex chars

The providerId is credential, the table's label for email-and-password accounts, and the stored password column holds two hexadecimal fields of 32 and 128 characters joined by a colon. The plain password does not appear anywhere in it.

The browser check in the last section shows what the page's JavaScript can see while signed in: document.cookie sees: "". That is the empty string, which is exactly what HttpOnly promises.

Symptom: you try to read document.cookie or localStorage to decide whether the user is signed in, and you get an empty string. Cause: the session cookie is HttpOnly by design, so page JavaScript cannot see it, and Better Auth never put a copy in localStorage. Fix: ask the server. In React that is useSession() from the Better Auth client (Concept 4), which calls the server's session endpoint, and the browser attaches the cookie itself.

Concept 3: the gate in front of every route, and the Origin check

Middleware is a function that runs before a route handler and can either stop the request with its own response or pass control onward by calling next(). Putting the session check in middleware means the check lives in one place at the edge of the API. Any route registered behind it inherits the check, and the handler never runs for an anonymous caller. Otherwise every handler would need its own check, and one forgotten check would be a hole.

import { Hono } from "hono";
import { createMiddleware } from "hono/factory";
import type { Auth } from "./auth";

type Session = NonNullable<Awaited<ReturnType<Auth["api"]["getSession"]>>>;
type Env = { Variables: { session: Session } };

export function createApp(auth: Auth) {
  const app = new Hono();

  // Better Auth owns everything under /api/auth: sign-up, sign-in,
  // sign-out, get-session. Hono hands it the raw Web Request.
  app.all("/api/auth/*", (c) => auth.handler(c.req.raw));

  // The gate: every other /api route needs a live session or gets a 401.
  const requireSession = createMiddleware<Env>(async (c, next) => {
    const session = await auth.api.getSession({ headers: c.req.raw.headers });
    if (!session) return c.json({ error: "sign in first" }, 401);
    c.set("session", session);
    await next();
  });

  app.get("/api/notes", requireSession, (c) => {
    const { user } = c.get("session");
    return c.json({
      owner: user.email,
      notes: [`Welcome, ${user.name}. Only a signed-in user can read this.`],
    });
  });

  app.get("/api/health", (c) => c.json({ ok: true }));

  return app;
}

The first block of types derives the shape of a session from Better Auth's own getSession return type, so the handler's user.email is type-checked. app.all("/api/auth/*", ...) forwards every HTTP method for those URLs to auth.handler, passing c.req.raw, the Web Standard request. The docs say this path must match Better Auth's basePath, which defaults to /api/auth, and that the route should be mounted before any catch-all route. The gate is built with Hono's createMiddleware. It calls auth.api.getSession, the server-side function the docs describe for reading a session, and passes the raw request headers. Those headers carry the Cookie header, so Better Auth can read and verify the cookie itself. No session means a 401 with a JSON body and no handler call. A session is stored on the context with c.set, and /api/notes reads it back. /api/health is deliberately outside the gate, so a script can ask "is the server up?" without signing in.

Now the part that protects against other websites. The same-origin policy is the browser rule that decides which pages may talk to which servers. An origin is the protocol, host and port of a URL. Different ports on the same host are different origins. Scripts may freely send requests to the server their page came from, but not read responses from other servers unless those servers opt in with CORS (Cross-Origin Resource Sharing). That rule governs what a script can read. It does not stop a browser from sending a request, with your cookies attached, when some other site's page causes it. That attack is CSRF (cross-site request forgery): a malicious page makes your browser fire a request at the real site, and the real site, seeing a valid cookie, obeys.

Better Auth's documented defenses, in the order its security page lists them: it prefers non-simple requests for state-changing operations, such as requiring a JSON content type, which browsers do not let a plain cross-site form send. It validates each request's Origin header (a header the browser attaches naming the origin that started the request) against trustedOrigins, and rejects untrusted ones; by default it trusts the base URL. Session cookies use SameSite=Lax, which keeps browsers from sending them on most cross-site requests. And for first-login CSRF on sign-in and sign-up, it checks Fetch Metadata and Origin/Referer, rejecting untrusted origins.

The lab exercises the origin rule. The test sends sign-in with Origin: http://evil.example, and the curl step does the same through the proxy. Both get:

untrusted origin: 403 {"message":"Invalid origin","code":"INVALID_ORIGIN"}

The test asserts that no Set-Cookie header comes back. The server log, from the curl step, records the refusal:

2026-10-29T20:35:29.306Z ERROR [Better Auth]: Invalid origin: http://evil.example

The wrong guess here is to conclude "no Origin means open door". Probe 2 sends a sign-in with no Origin header at all, as curl or a server script would:

2. sign-in with no Origin:   200 1 cookie(s) set

That matches the documented fallback: only when a request carries neither Fetch Metadata headers nor an Origin/Referer header (typical of non-browser clients) does Better Auth behave permissively, so server-to-server and native clients are not rejected. That trade is reasonable because CSRF needs the victim's browser: the attack works by borrowing cookies the browser already holds. A script on someone's own machine has no cookies of yours to borrow, so to sign in as you it still needs your password, which is exactly what a sign-in endpoint is supposed to demand.

Symptom: under bun test, the "untrusted origin is refused" test failed. This was the output from before the fix:

untrusted origin: 200 {"redirect":false,"token":"GRk8kSdaXlXi3EEOd5FMuymMyUDqaVeb","user":{"name":"Ada","email":"[email protected]","emailVerified":false,"image":null,"createdAt":"2026-10-29T20:29:36.489Z","updatedAt":"2026-10-29T20:29:36.489Z","id":"AaMUTqHMfOtLhy4j4nJ88eGCnUsCxWXg"}}
89 |     console.log("untrusted origin:", res.status, JSON.stringify(body));
Expected: 403
Received: 200
(fail) sign-in and sign-out > sign-in from an untrusted origin is refused [52.09ms]
Expected: 0
Received: 1
 6 pass
 2 fail

The second failure (Expected: 0, Received: 1) was the sign-out test counting session records. The sign-in that production rules would refuse had succeeded and created an extra session. Cause: in better-auth 1.7.14, the installed source decides whether to skip the origin check like this (node_modules/better-auth/dist/context/create-context.mjs, line 211):

skipOriginCheck: options.advanced?.disableOriginCheck !== void 0 ? options.advanced.disableOriginCheck : isTest() ? true : false

and isTest is defined in node_modules/@better-auth/core/dist/env/env-impl.mjs, line 36:

const isTest = () => nodeENV === "test" || toBoolean(env.TEST);

bun test sets NODE_ENV to "test", so with no explicit setting the check was skipped. Your tests would then run under looser rules than production and could not catch a broken origin rule. Fix: set advanced: { disableOriginCheck: false } explicitly, as server/auth.ts does, so the tests see the production rule. Never set it to true in production: the security docs say disableOriginCheck disables URL validation and CSRF protection. With the line in place, the same test returns 403 and all 8 tests pass.

Concept 4: the React client, through Vite's proxy

The browser needs two things from the server: the cookie, and permission to use it. By the same-origin policy, page JavaScript can freely call the server the page came from, and other servers only if they opt in with CORS. So the simplest design makes the API look like it lives on the page's own origin. Vite's server.proxy option does that during development: you give it a path prefix and a target, and the dev server forwards matching requests there. It is a development-server feature only. In a deployed app, a reverse proxy in front of both the page and the API plays the same role, which is the arrangement the Better Auth cookie docs recommend for Safari (more on that at the end of this section).

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: {
    // The browser only talks to Vite on 127.0.0.1:5173. Vite forwards /api
    // (including /api/auth) to the Bun API on :8787, so the session cookie
    // is set and sent by the page's own origin.
    proxy: { "/api": "http://127.0.0.1:8787" },
  },
});

The plugin line makes Vite understand React's JSX. The proxy entry says: any request whose path starts with /api goes to the Bun API on port 8787. The page loads from 127.0.0.1:5173, so when it calls /api/auth/sign-in/email or /api/notes the browser sees the same origin. The Set-Cookie header the API sends travels back through the proxy, and the browser files the cookie under the page's host, 127.0.0.1, and sends it back with the page's own requests. That origin matches APP_ORIGIN in server/auth.ts, which is why that constant is the only trusted origin.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>notebox</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

The HTML page is an empty shell with one div and one script tag. Vite serves it and compiles src/main.tsx, which mounts the App component into that div inside StrictMode. StrictMode is a React development aid that runs some code twice to expose side-effect bugs. The browser check later runs with it on, so the effect code in App is tested under that condition.

import { createAuthClient } from "better-auth/react";

// No baseURL: the API is reached on the page's own origin through the
// Vite proxy, so the client uses window.location.origin + /api/auth.
export const authClient = createAuthClient();

createAuthClient comes from the React entry point of the library. The docs say you pass the base URL of your auth server, but if the auth server runs on the same domain as the client, you can skip it. Because of the proxy, that is our case, and the comment in the file records the consequence: the client falls back to the page's own origin with /api/auth appended. Fewer settings means fewer places for the client and server to disagree about addresses.

import { useEffect, useState, type FormEvent } from "react";
import { authClient } from "./auth-client";

type Notes = { owner: string; notes: string[] };

export function App() {
  const { data: session, isPending } = authClient.useSession();
  const [notes, setNotes] = useState<Notes | null>(null);
  const [error, setError] = useState("");

  // Ask the protected route only once a session exists. The browser
  // attaches the HttpOnly cookie itself; this code never sees it.
  useEffect(() => {
    if (!session) return setNotes(null);
    fetch("/api/notes")
      .then((res) => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`))))
      .then(setNotes, (err: Error) => setError(err.message));
  }, [session?.session.id]);

  async function onSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const form = new FormData(e.currentTarget);
    const email = String(form.get("email"));
    const password = String(form.get("password"));
    const mode = (e.nativeEvent as SubmitEvent).submitter?.getAttribute("value");
    const { error } =
      mode === "sign-up"
        ? await authClient.signUp.email({ email, password, name: email.split("@")[0] })
        : await authClient.signIn.email({ email, password });
    setError(error ? `${error.status} ${error.code ?? ""} ${error.message ?? ""}`.trim() : "");
  }

  if (isPending) return <p>Checking session…</p>;

  if (!session) {
    return (
      <form onSubmit={onSubmit}>
        <input name="email" type="email" placeholder="email" required />
        <input name="password" type="password" placeholder="password (8+ chars)" required />
        <button value="sign-in">Sign in</button>
        <button value="sign-up">Create account</button>
        {error && <p role="alert">{error}</p>}
      </form>
    );
  }

  return (
    <main>
      <p>Signed in as {session.user.email}</p>
      <ul>{notes?.notes.map((n) => <li key={n}>{n}</li>)}</ul>
      {error && <p role="alert">{error}</p>}
      <button onClick={() => authClient.signOut()}>Sign out</button>
    </main>
  );
}

Start with useSession(), which the docs say returns { data, isPending, error, refetch }. While isPending is true, the component shows "Checking session…", because the page cannot know yet whether the HttpOnly cookie is valid. Only the server knows, and the hook asks it. The useEffect runs when the session's id changes: with no session it clears the notes, and with a session it fetches /api/notes at a relative URL, so the request goes to the page's own origin and through the proxy. The browser adds the cookie automatically. The gate on the server does the rest. Keying the effect on the session id, rather than on the whole object, avoids refetching when nothing about the sign-in changed.

The form has two buttons with different value attributes. In onSubmit, e.nativeEvent.submitter is the button that triggered the submit, so one form serves both "Sign in" and "Create account". Both client calls return { data, error }, as the docs describe, so there are no exceptions to catch. A failure becomes a short string such as an HTTP status, a machine code and a message, which the role="alert" paragraph displays. Sign-up derives a display name from the part of the email before the @. The page lets the user type any password. The form's placeholder says "8+ chars", but the real limit is the server's. Probe 3 sent a 5-character password:

3. 5-character password:    400 {"message":"Password too short","code":"PASSWORD_TOO_SHORT"}

The docs say passwords should be at least 8 characters, 128 at most by default. The server enforces it whatever the form allows, which is the right place, because anyone can skip your form and call the API directly.

The browser check drives this page in Bun.WebView (the script is shown in the last section). It signs up as [email protected], reads document.cookie, reloads the page, then signs out. These are the lines it printed on Linux, and the macOS run printed the same:

after Create account: Signed in as [email protected] | Welcome, grace. Only a signed-in user can read this. | Sign out
document.cookie sees: ""
after reload:         Signed in as [email protected] | Welcome, grace. Only a signed-in user can read this. | Sign out
after Sign out:       Sign inCreate account

The first line shows the whole chain worked: sign-up, cookie, session lookup, gated notes request. The second proves the cookie is invisible to page code. The third shows the user stays signed in across a full reload, with nothing but the cookie to carry the state. The last shows signing out returns the form. Those lines answer the requirement that the browser stay signed in.

Symptom: you point the client straight at the API, with createAuthClient({ baseURL: "http://127.0.0.1:8787" }), from a page loaded at http://127.0.0.1:5173. Cause: a different port is a different origin, so every auth call becomes a cross-origin request. This is documented behaviour; the lab did not run this configuration. Fix: per the Hono integration docs, register Hono's CORS middleware before the auth route, use an explicit origin instead of * when credentials is on, and add the same origin to trustedOrigins. Or keep the proxy, and none of that is needed. The cookie docs also warn that Safari's tracking prevention may block auth cookies entirely when the API lives on a different domain than the frontend, and recommend a reverse proxy so requests appear first-party. That is what the docs say. This lab ran no Safari test, only the WebKit-based Bun.WebView on macOS.

Putting it together

run-lab.sh runs the whole story from install to browser check on a throwaway database.

#!/usr/bin/env bash
# Runs the whole lab: install, typecheck, tests, a real database, the API,
# Vite's dev server, and curl through the proxy exactly as a browser would.
set -euo pipefail
set -m # background jobs get their own process groups, so cleanup kills whole trees
cd "$(dirname "$0")"
export BETTER_AUTH_SECRET="$(openssl rand -base64 32)"
export DB_FILE="$(mktemp -d)/notebox.sqlite"
APP=http://127.0.0.1:5173
JAR="$(mktemp)"
LOG="$(mktemp)"

bun --version
bun install --frozen-lockfile
bun run typecheck
bun test
bun run migrate

bun server/main.ts > "$LOG" 2>&1 & API=$!
bun --bun vite --host 127.0.0.1 --port 5173 --strictPort --logLevel warn & VITE=$!
trap 'kill -- -$API -$VITE 2>/dev/null; rm -f "$JAR" "$LOG" /tmp/notebox-body' EXIT
for _ in $(seq 50); do curl -sf "$APP/api/health" >/dev/null && break; sleep 0.2; done

show() { printf '%-34s ' "$1"; shift; curl -s -o /tmp/notebox-body -w '%{http_code} ' "$@"; cat /tmp/notebox-body; echo; }
creds='{"name":"Ada","email":"[email protected]","password":"correct horse battery"}'
login='{"email":"[email protected]","password":"correct horse battery"}'

show "notes, no cookie"          "$APP/api/notes"
show "sign-up (page origin)"     -c "$JAR" -H "Origin: $APP" -H 'content-type: application/json' -d "$creds" "$APP/api/auth/sign-up/email"
awk '$6 == "better-auth.session_token" {print "cookie jar:", ($1 ~ /HttpOnly/ ? "HttpOnly" : "plain"), $3, $6 "=<token>.<signature>"}' "$JAR"
show "notes, with cookie"        -b "$JAR" "$APP/api/notes"
show "sign-in, evil origin"      -H 'Origin: http://evil.example' -H 'content-type: application/json' -d "$login" "$APP/api/auth/sign-in/email"
show "sign-out"                  -b "$JAR" -H "Origin: $APP" -X POST -H 'content-type: application/json' -d '{}' "$APP/api/auth/sign-out"
show "notes, same cookie after"  -b "$JAR" "$APP/api/notes"

echo "--- browser check"
bun smoke.ts "$APP/"
echo "--- API server log"
cat "$LOG"

The top creates a fresh 32-byte secret with openssl rand -base64 32 and a database file in a new temporary folder, so every run starts clean and no secret is stored anywhere. set -m gives each background job its own process group, which lets the trap kill the API and Vite together with anything they spawned; the kill -- -$API -$VITE form targets whole groups. The script installs from the lockfile (a file created by an earlier bun install, so run bun install once yourself when rebuilding the project from these files), type-checks, runs the tests, and migrates. It then starts the API with its output in a log file and Vite on port 5173. The for loop polls /api/health through the proxy until it answers, which tests the proxy path before the real requests begin. show prints a label, then the status and body of a curl call. The calls go to port 5173, not 8787, and the sign-up and sign-out calls include Origin: $APP, imitating a browser. The -c and -b flags write and read the cookie jar, so curl behaves like a browser that keeps cookies. One more detail: Vite starts as bun --bun vite. The --bun flag runs Vite's command-line tool under Bun instead of Node. The macOS machine this lab ran on has no Node installed, and the plain ./node_modules/.bin/vite launcher failed there because it expects Node; with --bun the same script runs on both machines.

The browser half is smoke.ts:

// Browser check against the Vite dev server (StrictMode on, /api proxied).
// Usage: bun smoke.ts http://127.0.0.1:5173/
// Bun.WebView is experimental: WebKit on macOS, installed Chrome elsewhere.
export {}; // a module, so top-level await type-checks
const base = process.argv[2] ?? "http://127.0.0.1:5173/";
const text = () => view.evaluate(`document.body.innerText.replace(/\\n+/g, " | ")`);
async function until(expr: string, ms = 5000) {
  const end = Date.now() + ms;
  while (!(await view.evaluate(`Boolean(${expr})`))) {
    if (Date.now() > end) throw new Error(`timed out waiting for ${expr}`);
    await Bun.sleep(25);
  }
}
const signedIn = `document.body.innerText.includes("Signed in as")`;
const form = `document.querySelector("form")`;

await using view = new Bun.WebView({ width: 800, height: 600 });
await view.navigate(base);
await until(form);
await view.evaluate(`(() => {
  document.querySelector("[name=email]").value = "[email protected]";
  document.querySelector("[name=password]").value = "compilers are people too";
  document.querySelector("button[value=sign-up]").click();
})()`);
await until(`${signedIn} && document.querySelector("li")`);
console.log("after Create account:", await text());
console.log("document.cookie sees:", JSON.stringify(await view.evaluate(`document.cookie`)));

await view.navigate(base); // a fresh page load: the cookie is all that is left
await until(`${signedIn} && document.querySelector("li")`);
console.log("after reload:        ", await text());

await view.evaluate(`[...document.querySelectorAll("button")].find((b) => b.textContent === "Sign out").click()`);
await until(form);
console.log("after Sign out:      ", await text());

Bun.WebView opens a real web view that runs the page's JavaScript, which Bun's docs mark as experimental, using WebKit on macOS and an installed Chrome elsewhere. view.evaluate runs a string of JavaScript inside the page and returns the result. The helper until polls an expression every 25 milliseconds and throws after five seconds, because React renders asynchronously and the script must wait for the page to reach each state. The script fills the form, clicks "Create account", waits for the signed-in text and the first note, and prints the visible text. It reads document.cookie, navigates to the same URL again for a fresh load, and finally finds the "Sign out" button and clicks it. The await using line closes the view when the script ends.

This is the Linux output of the whole curl and browser portion, copied from the run log (the bun install, typecheck, test and migrate output appeared in earlier sections):

notes, no cookie                   401 {"error":"sign in first"}
sign-up (page origin)              200 {"token":"mBfJzP93inK6blYGxNtzj3HK27mCrzDN","user":{"name":"Ada","email":"[email protected]","emailVerified":false,"image":null,"createdAt":"2026-10-29T20:35:29.276Z","updatedAt":"2026-10-29T20:35:29.276Z","id":"OGRY8T5PhubH80akKnG34FH1CzmxeRDO"}}
cookie jar: HttpOnly / better-auth.session_token=<token>.<signature>
notes, with cookie                 200 {"owner":"[email protected]","notes":["Welcome, Ada. Only a signed-in user can read this."]}
sign-in, evil origin               403 {"message":"Invalid origin","code":"INVALID_ORIGIN"}
sign-out                           200 {"success":true}
notes, same cookie after           401 {"error":"sign in first"}
--- browser check
after Create account: Signed in as [email protected] | Welcome, grace. Only a signed-in user can read this. | Sign out
document.cookie sees: ""
after reload:         Signed in as [email protected] | Welcome, grace. Only a signed-in user can read this. | Sign out
after Sign out:       Sign inCreate account
--- API server log
notebox API on http://127.0.0.1:8787/
2026-10-29T20:35:29.306Z ERROR [Better Auth]: Invalid origin: http://evil.example

Read the table top to bottom as the story of the problem. A request with no cookie gets 401, so the gate holds. Sign-up from the page's origin succeeds and plants an HttpOnly cookie. The same route then returns Ada's notes, so accounts and the signed-in state work. A sign-in claiming to come from evil.example is refused with 403, which covers the cross-site trick. Signing out deletes the session, and the very same cookie now gets 401 again. The browser lines show the same thing in a real page, including survival across a reload. The API log has only the one line for the refused origin, which is the server noting it rejected a request. The complete log also ends with a line from Vite saying it was stopped by SIGTERM; that is the cleanup step, not a failure.

The macOS run (Bun 1.4.4, arm64) printed the same results as the Linux run (Bun 1.4.2, x64): the same 8 passing tests, the same status codes, the same cookie attributes, and the same browser lines. Only timestamps, timings and the random token values differ. That is what the lab shows. Rate limiting (which the docs say is built in), Safari behaviour, HTTPS and the Secure flag in production, and Cloudflare Workers (where the docs say nodejs_compat is needed) are things the docs describe and this lab did not test.

Check yourself

  1. Why can a script injected into the page not steal the session cookie here? The cookie is HttpOnly, so document.cookie cannot read it. The browser check printed document.cookie sees: "" while signed in. MDN says this helps mitigate XSS.
  1. After sign-out, the browser may still hold the old cookie. Why does it stop working? The cookie only names a session record, and sign-out deletes that record. The test counts 0 sessions afterward, and both the test and curl get a 401 when they present the same cookie.
  1. **Why did the sign-in with Origin: http://evil.example get 403, but the sign-in with no Origin header got 200?** An untrusted Origin is rejected against trustedOrigins. With no Origin and no Fetch Metadata, which is typical of non-browser clients, the docs say Better Auth falls back to permissive behaviour so scripts and servers still work. The attack needs the victim's browser and the cookies it holds, and the permissive fallback is reserved for requests that carry none of the headers a browser page sends.
  1. **Why does server/auth.ts say disableOriginCheck: false when false looks like the default?** In better-auth 1.7.14 the default skips the check when NODE_ENV is "test", and bun test sets that. Without the explicit line the untrusted-origin test got 200 instead of 403. Never set it to true in production, since the docs say it disables URL validation and CSRF protection.
  1. **The lab's cookie has no Secure flag. What do the docs say changes in production?** Cookies are secure when the server runs in production mode, and useSecureCookies: true forces it. A Secure cookie is only sent over HTTPS, and insecure http: sites cannot set one, which is why the plain-HTTP lab omits it. Production behaviour was not tested here.

Sources

  1. Better Auth docs, Hono Integration: www.better-auth.com/docs/integrations/hono
  2. Better Auth docs, SQLite adapter: www.better-auth.com/docs/adapters/sqlite
  3. Better Auth docs, Database: www.better-auth.com/docs/concepts/database
  4. Better Auth docs, Session Management: www.better-auth.com/docs/concepts/session-management
  5. Better Auth docs, Cookies: www.better-auth.com/docs/concepts/cookies
  6. Better Auth docs, Security: www.better-auth.com/docs/reference/security
  7. Better Auth docs, Email & Password: www.better-auth.com/docs/authentication/email-password
  8. Better Auth docs, Client: www.better-auth.com/docs/concepts/client
  9. Better Auth docs, Installation: www.better-auth.com/docs/installation
  10. Better Auth docs, API: www.better-auth.com/docs/concepts/api
  11. MDN, Using HTTP cookies: developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies
  12. David Flanagan, JavaScript: The Definitive Guide, 7th ed., section 15.12.2 "Cookies" (PDF p. 932) and "Cookie attributes: lifetime and scope" (PDF p. 934)
  13. David Flanagan, JavaScript: The Definitive Guide, 7th ed., chapter 15, "The same-origin policy" (PDF p. 738) and "Cross-site scripting" (PDF p. 740)
  14. Vite docs, Server Options (server.proxy): vite.dev/config/server-options
  15. Hono docs, Factory createMiddleware: hono.dev/docs/helpers/factory, and Testing with app.request: hono.dev/docs/guides/testing
  16. Bun docs, WebView: bun.com/docs/runtime/webview, and bun:test: bun.com/docs/test/writing