Bastion
Bastion is a JSON API server in Rust that is hardened by default. This is how to run it, and how to put any frontend in front of it.
The server handles accounts, sessions, and a small CRUD resource. It hashes passwords with Argon2id, rotates single-use refresh tokens, rate-limits per IP and per account, and bounds every request and connection. None of that is optional or bolted on — it is wired in and covered by 151 tests, 31 of which attack the running server.
This site covers running it, what it costs at load, and wiring a frontend to it in whatever you already use: vanilla JavaScript, TypeScript, React, Vue, Svelte, Next.js, Tailwind, or plain CSS.
Quickstart
Rust 1.85 or newer. No database server — SQLite file, created on first run.
PostgreSQL, MySQL and MongoDB are opt-in builds; the APP_DATABASE_URL
scheme picks between them.
git clone https://github.com/umkara/bastion.git
cd bastion
cp .env.example .env
sed -i '' "s|^APP_JWT_SECRET=.*|APP_JWT_SECRET=$(openssl rand -base64 48)|" .env
mkdir -p data && cargo run
The server listens on 127.0.0.1:8443. It refuses to start without
a signing key of at least 32 bytes, and refuses to start in
production without TLS.
curl http://127.0.0.1:8443/health/ready
# {"status":"ready","version":"0.1.0"}
Create an account and log in — passwords are 12 characters minimum:
curl -X POST http://127.0.0.1:8443/api/v1/auth/register \
-H 'content-type: application/json' \
-d '{"email":"me@example.com","password":"correct-horse-battery-staple"}'
curl -X POST http://127.0.0.1:8443/api/v1/auth/login \
-H 'content-type: application/json' \
-d '{"email":"me@example.com","password":"correct-horse-battery-staple"}'
Performance
Measured, not estimated. Apple M2, 8 cores, release build, SQLite in WAL mode,
loopback, ab -k with 20,000 requests at concurrency 50, median
of three runs. Rate limits were raised for the run so the numbers reflect the
server rather than the limiter.
ab instances sum to the same total as
one — but it does share the same eight cores as the server, so a dedicated
machine would go higher.
| Endpoint | Work done | req/s | p50 | p95 | p99 |
|---|---|---|---|---|---|
GET /health/live |
Routing and the full middleware stack | 53,136 | 1 ms | 1 ms | 2 ms |
GET /api/v1/notes |
JWT verify + SQLite read | 30,409 | 2 ms | 2 ms | 3 ms |
GET /style.css |
5.9 KB static file from disk | 7,660 | 2 ms | 26 ms | 47 ms |
POST /api/v1/auth/login |
Argon2id, deliberately expensive | 73–187 | 193 ms | — | 311 ms |
Static files are the slowest thing here
A 5.9 KB stylesheet serves at roughly a quarter the rate of a JSON response, because every request reads it from disk — there is no in-memory asset cache. That is fine for a docs site or an internal tool. If you are serving a large bundle under real traffic, put a CDN or a reverse proxy in front and let the Rust server do what it is good at, which is the API.
Why login is slow on purpose
That last row is not a defect. Argon2id reserves 19 MiB and burns CPU by design — that is what makes stolen password hashes expensive to crack. A single login costs about 24 ms sequentially. Under concurrency the server admits only as many hashes at once as it has cores, so a login flood queues and sheds instead of exhausting memory. It is also the noisiest measurement here — saturating every core makes throughput swing with thermal state, hence the range rather than a single figure.
Reproduce it
cargo build --release
APP_RATE_LIMIT_BURST=1000000 APP_AUTH_RATE_LIMIT_BURST=1000000 \
./target/release/bastion
ab -k -c 50 -n 20000 http://127.0.0.1:8443/health/live
ab -k -c 50 -n 20000 -H "authorization: Bearer $TOKEN" \
http://127.0.0.1:8443/api/v1/notes
Loopback numbers omit real network latency, and a production deployment adds TLS. Treat these as the ceiling the application imposes, not a promise about your infrastructure.
Serving a frontend
Point the server at a directory of built assets. Anything that does not match
an API route is served from it, and unknown paths fall back to
index.html so client-side routing works.
APP_STATIC_DIR=./dist cargo run
This page is served that way. Build your frontend however you like, then hand
over the output directory — dist/ for Vite, build/
for Create React App, .output/public for Nuxt.
APP_CORS_ALLOWED_ORIGINS empty. Only set it when the
frontend is genuinely on another origin.
Or put a proxy in front
Equally valid, and common in production: let nginx or Caddy serve the static
files and proxy /api to the server.
# Caddyfile
bastionrs.dev {
handle /api/* {
reverse_proxy 127.0.0.1:8443
}
handle {
root * /srv/frontend
try_files {path} /index.html
file_server
}
}
If you do this, set APP_TRUST_PROXY_HEADERS=true so rate limiting
sees real client addresses instead of the proxy's — but only when the proxy is
yours, since otherwise a client can forge the header and bypass the limit.
The API client
Every framework below shares the same core. The only part that needs care is token refresh: access tokens last 15 minutes, refresh tokens are single-use and rotate on every exchange. Store the newest one and never retry with an old one — replaying a spent token is treated as theft and revokes the whole session chain.
TypeScript
// api.ts — framework-agnostic, works in any of the setups below
const BASE = ""; // same origin when served by the Rust server
type Tokens = { access_token: string; refresh_token: string; expires_in: number };
let access: string | null = null;
let refresh: string | null = localStorage.getItem("refresh") ?? null;
function store(t: Tokens) {
access = t.access_token;
refresh = t.refresh_token; // always the newest one
localStorage.setItem("refresh", t.refresh_token);
}
export async function login(email: string, password: string) {
const r = await fetch(`${BASE}/api/v1/auth/login`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (!r.ok) throw await apiError(r);
store(await r.json());
}
/** Fetch with the access token, refreshing once on 401. */
export async function api(path: string, init: RequestInit = {}): Promise<Response> {
const send = () =>
fetch(`${BASE}${path}`, {
...init,
headers: {
...init.headers,
...(access ? { authorization: `Bearer ${access}` } : {}),
},
});
let res = await send();
if (res.status === 401 && refresh) {
const r = await fetch(`${BASE}/api/v1/auth/refresh`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ refresh_token: refresh }),
});
if (!r.ok) { // refresh rejected: the session is over
access = refresh = null;
localStorage.removeItem("refresh");
throw await apiError(r);
}
store(await r.json());
res = await send(); // one retry, never a loop
}
return res;
}
async function apiError(r: Response) {
const body = await r.json().catch(() => null);
return new Error(body?.error?.message ?? `HTTP ${r.status}`);
}
Vanilla JavaScript
The same file with the types removed — no build step, load it as a module.
<script type="module" src="/api.js"></script>
JS & TS frameworks
The first five build to static files, which is all the server needs: build,
then point APP_STATIC_DIR at the output. Next.js is the
exception — it runs as its own process and calls the API server-side.
No build step at all. Write files, serve the folder.
public/
index.html
api.js
style.css
APP_STATIC_DIR=./public cargo run
<!-- index.html -->
<script type="module">
import { login, api } from "/api.js";
document.querySelector("#login").addEventListener("submit", async (e) => {
e.preventDefault();
await login(email.value, password.value);
const notes = await (await api("/api/v1/notes")).json();
render(notes.items);
});
</script>
Note the module lives in a file rather than inline — the page CSP does not
allow 'unsafe-inline', which is what stops an injected
<script> from executing. See
CSP & headers.
pnpm create vite frontend --template vanilla-ts
cd frontend && pnpm install && pnpm build
APP_STATIC_DIR=./frontend/dist cargo run
For development, run Vite's dev server and proxy the API to Rust:
// vite.config.ts
export default {
server: {
proxy: { "/api": "http://127.0.0.1:8443" },
},
};
That keeps hot reload while the API stays same-origin from the browser's point of view, so no CORS configuration is needed in development either.
pnpm create vite frontend --template react-ts
cd frontend && pnpm install && pnpm build
APP_STATIC_DIR=./frontend/dist cargo run
// useAuth.ts — the client above, wrapped in a hook
import { useState, useCallback } from "react";
import { login as apiLogin, api } from "./api";
export function useAuth() {
const [user, setUser] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const signIn = useCallback(async (email: string, password: string) => {
try {
await apiLogin(email, password);
setUser(email);
setError(null);
} catch (e) {
setError((e as Error).message); // "authentication required"
}
}, []);
return { user, error, signIn };
}
React Router works unchanged: the server's index.html fallback
means a deep link like /notes/42 reaches your router instead of
returning 404.
pnpm create vite frontend --template vue-ts
cd frontend && pnpm install && pnpm build
APP_STATIC_DIR=./frontend/dist cargo run
// stores/auth.ts — Pinia
import { defineStore } from "pinia";
import { login as apiLogin, api } from "../api";
export const useAuth = defineStore("auth", {
state: () => ({ user: null as string | null, error: null as string | null }),
actions: {
async signIn(email: string, password: string) {
try {
await apiLogin(email, password);
this.user = email;
} catch (e) {
this.error = (e as Error).message;
}
},
},
});
pnpm create vite frontend --template svelte-ts
cd frontend && pnpm install && pnpm build
APP_STATIC_DIR=./frontend/dist cargo run
// auth.ts — a store
import { writable } from "svelte/store";
import { login as apiLogin } from "./api";
export const user = writable<string | null>(null);
export async function signIn(email: string, password: string) {
await apiLogin(email, password);
user.set(email);
}
For SvelteKit, use adapter-static so the build produces plain
files. Server-side rendering would need the API reachable from the Node
process, which is a different deployment shape than this server assumes.
The odd one out, and the only entry here that does not go behind
APP_STATIC_DIR. A static export emits an inline bootstrap
script, and the page CSP is script-src 'self' with no
unsafe-inline, so it would never hydrate. Next runs as its own
Node process and calls this server from the server side.
Browser ──session cookie──> Next (Node) ──Bearer token──> this server
│
└── its own database: whatever your app owns
That is the better arrangement anyway. Tokens never reach the browser, so
there is no refresh token in localStorage for an XSS to walk
off with, and because every call is server-to-server the browser never
issues a cross-origin request — leave
APP_CORS_ALLOWED_ORIGINS empty.
Scaffold
Written against Next 16, App Router, server actions.
pnpm create next-app@latest my-app --ts --app --src-dir --tailwind
cd my-app
# .env.local
BASTION_URL=http://127.0.0.1:8443
SESSION_SECRET=$(openssl rand -base64 32)
The client
One module, marked server-only so an accidental import from a
client component fails the build rather than shipping your token handling
to the browser.
// src/lib/bastion.ts
import "server-only";
const BASE = `${process.env.BASTION_URL}/api/v1`;
export interface Tokens {
access_token: string;
refresh_token: string;
expires_in: number;
}
export async function login(email: string, password: string): Promise<Tokens> {
const res = await fetch(`${BASE}/auth/login`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: email.trim().toLowerCase(), password }),
cache: "no-store",
});
if (res.status === 401) throw new Error("invalid credentials");
if (!res.ok) throw new Error(`bastion: HTTP ${res.status}`);
return res.json();
}
export async function refresh(refreshToken: string): Promise<Tokens> {
const res = await fetch(`${BASE}/auth/refresh`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
cache: "no-store",
});
if (!res.ok) throw new Error(`refresh failed: HTTP ${res.status}`);
return res.json();
}
export async function logout(refreshToken: string): Promise<void> {
await fetch(`${BASE}/auth/logout`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
cache: "no-store",
});
}
export async function apiFetch(path: string, accessToken: string, init?: RequestInit) {
return fetch(`${BASE}${path}`, {
...init,
headers: { ...init?.headers, authorization: `Bearer ${accessToken}` },
cache: "no-store",
});
}
Normalising the email with trim().toLowerCase() matches what
this server does before it looks the account up. Skip it and
Alice@example.com becomes one account here and two
rows in your database.
Where the tokens live
In a table of your own, one row per session — not one per user. The cookie holds the row's id and nothing else.
CREATE TABLE session (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, -- this server's uuid, from the `sub` claim
email TEXT NOT NULL,
role TEXT NOT NULL,
access_token TEXT NOT NULL, -- encrypt these at rest
refresh_token TEXT NOT NULL,
expires_at INTEGER NOT NULL, -- ms since epoch, from the `exp` claim
generation INTEGER NOT NULL DEFAULT 0, -- bumped on every rotation
locked_until INTEGER, -- the refresh lease
created_at INTEGER NOT NULL
);
generation and locked_until look like overhead
until you read the refresh section below — they are what stop two requests
refreshing at once. Seal the two token columns with AES-256-GCM or your
platform's equivalent: a refresh token in plaintext is a standing account
takeover for anyone who gets a copy of the file.
Signing in
A server action. The tokens go into your database keyed by a session id; the cookie carries the id and nothing else.
// src/app/sign-in/actions.ts
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { login } from "@/lib/bastion";
import { createSession } from "@/lib/session";
export async function signIn(_state: unknown, form: FormData) {
let tokens;
try {
tokens = await login(String(form.get("email")), String(form.get("password")));
} catch {
return { error: "Invalid email or password." };
}
const sessionId = await createSession(String(form.get("email")), tokens);
(await cookies()).set("session", sessionId, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
redirect("/");
}
Knowing who the user is, without asking
There is no /me endpoint, and the access token carries no
email claim — it carries sub (the user's uuid),
role, and the usual timestamps. So read the claims once at
sign-in and store them on your session alongside the email the form
already gave you.
// Decoding without verifying is safe *here* and nowhere else: this token
// arrived over TLS as the direct response to our own login call. Never do
// this to a token that came from a browser — that is what the signature
// is for, and this server checks it on every protected route anyway.
function claims(accessToken: string) {
const payload = Buffer.from(accessToken.split(".")[1], "base64url").toString();
return JSON.parse(payload) as { sub: string; role: "user" | "admin"; exp: number };
}
The session module
Everything above, tied together. requireUser is what your
pages call, and note what it does not do: reach the network.
// src/lib/session.ts
import "server-only";
import { cookies } from "next/headers";
import { notFound, redirect } from "next/navigation";
import { db } from "@/lib/db";
import { claims, type Tokens } from "@/lib/bastion";
import { seal } from "@/lib/crypto";
export async function createSession(email: string, tokens: Tokens) {
const { sub, role, exp } = claims(tokens.access_token);
const id = crypto.randomUUID();
db.prepare(
`INSERT INTO session (id, user_id, email, role, access_token, refresh_token,
expires_at, generation, locked_until, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, NULL, ?)`,
).run(
id, sub, email.trim().toLowerCase(), role,
seal(tokens.access_token), seal(tokens.refresh_token),
exp * 1000, Date.now(),
);
return id;
}
export async function getSession() {
const id = (await cookies()).get("session")?.value;
if (!id) return null;
return db.prepare("SELECT * FROM session WHERE id = ?").get(id) ?? null;
}
/** Redirects to sign-in, preserving where the user was headed. */
export async function requireUser(returnTo = "/") {
const session = await getSession();
if (!session) redirect(`/sign-in?next=${encodeURIComponent(returnTo)}`);
return session;
}
/** 404 rather than 403 — a 403 confirms the route exists. */
export async function requireAdmin() {
const session = await getSession();
if (session?.role !== "admin") notFound();
return session;
}
A page then costs one local read:
// src/app/account/page.tsx
import { requireUser } from "@/lib/session";
export default async function AccountPage() {
const session = await requireUser("/account");
return <p>Signed in as {session.email}</p>;
}
When you do need a live token
Changing a password is the usual case. Fetch a token, retry once if this server rejects one you believed was valid — a clock skew, or a revocation that landed between your expiry check and the call.
// src/app/account/password/actions.ts
"use server";
import { apiFetch } from "@/lib/bastion";
import { getAccessToken } from "@/lib/tokens";
import { requireUser } from "@/lib/session";
export async function changePassword(_state: unknown, form: FormData) {
const session = await requireUser("/account/password");
const res = await apiFetch("/auth/password", await getAccessToken(session.id), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
current_password: String(form.get("currentPassword")),
new_password: String(form.get("newPassword")),
}),
});
if (res.status === 401) return { error: "That current password is wrong." };
if (!res.ok) return { error: "Could not change the password." };
// 204. Every session on the account is now revoked, including this one —
// sign back in with the new password and replace the stored tokens, or send
// the user to the sign-in page. Doing nothing leaves them on a dead session.
return { ok: true };
}
/auth/password as a route where 401
means the password, not the token.
Refreshing — the part that bites
Refresh tokens are single-use, and losing the rotation race
revokes the entire family. Two concurrent refreshes therefore do not merely
waste a call — they sign the user out everywhere. An
async mutex in module scope will not save you: next
dev compiles route handlers in separate workers, and production runs
more than one instance.
Serialise it in the database instead. The lease is a compare-and-swap on
generation: the winner is whoever changes exactly one row.
// src/lib/tokens.ts
import "server-only";
import { refresh } from "@/lib/bastion";
import { db } from "@/lib/db";
import { open, seal } from "@/lib/crypto";
const LEASE_MS = 15_000;
const SKEW_MS = 30_000; // refresh this far ahead of expiry
export async function getAccessToken(sessionId: string): Promise<string> {
const row = db.prepare("SELECT * FROM session WHERE id = ?").get(sessionId);
if (!row) throw new Error("no session");
if (row.expires_at - SKEW_MS > Date.now()) return open(row.access_token);
const now = Date.now();
const won = db.prepare(
`UPDATE session SET locked_until = ?
WHERE id = ? AND generation = ?
AND (locked_until IS NULL OR locked_until < ?)`,
).run(now + LEASE_MS, sessionId, row.generation, now).changes === 1;
// Someone else is already refreshing. Poll until they bump the generation,
// bounded by the lease so a crashed holder cannot block anyone forever.
if (!won) return awaitRotation(sessionId, row.generation);
try {
const rotated = await refresh(open(row.refresh_token));
db.prepare(
`UPDATE session
SET access_token = ?, refresh_token = ?, expires_at = ?,
generation = generation + 1, locked_until = NULL
WHERE id = ? AND generation = ?`,
).run(
seal(rotated.access_token), seal(rotated.refresh_token),
Date.now() + rotated.expires_in * 1000, sessionId, row.generation,
);
return rotated.access_token;
} catch (error) {
// 429: the limiter rejected it before the handler, so the token is
// unspent — release and let the next caller try.
// Anything else: the outcome is unknown. Do NOT retry.
if (isRateLimited(error)) {
db.prepare("UPDATE session SET locked_until = NULL WHERE id = ?").run(sessionId);
} else {
db.prepare("DELETE FROM session WHERE id = ?").run(sessionId);
}
throw error;
}
}
Handle the two failure modes differently, because they are not the same failure. A 429 is rejected by the rate limiter before the handler runs, so the refresh token is unspent — release the lease and let the next caller try. A timeout or 5xx is ambiguous: the token may have been consumed with the response lost. Retrying it looks like replay and takes down every session in the family, so mark that credential dead and make the user sign in again. Losing one session beats losing all of them.
Signing out
Revoke the family here as well as dropping your own row, or the refresh token stays usable until it expires on its own.
// src/app/actions.ts
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { logout } from "@/lib/bastion";
import { db } from "@/lib/db";
import { open } from "@/lib/crypto";
export async function signOut() {
const jar = await cookies();
const id = jar.get("session")?.value;
if (id) {
const row = db.prepare("SELECT refresh_token FROM session WHERE id = ?").get(id);
// Best effort: a failed revocation must not block signing out. The family
// expires on its own; dropping the local row matters more right now.
if (row) await logout(open(row.refresh_token)).catch(() => {});
db.prepare("DELETE FROM session WHERE id = ?").run(id);
}
jar.delete("session");
redirect("/");
}
Next 16 specifics
-
cookies(),headers(),paramsandsearchParamsare all async —awaitthem. -
middleware.tsis nowproxy.ts. Either name still works, but do not put the authorization check there: treat it as a redirect for tidiness and gate in the server component or action that actually touches data. -
Native modules need
serverExternalPackages: ["better-sqlite3"]innext.config.ts, or the.nodebinding is lost to the bundler. - A session cookie set by a server action lands on the response. Reading the session again later in that same action still reports an anonymous visitor — take what you need from the value you already have.
That is the whole integration
Nothing above needs an authentication library. Six files — a client, a crypto helper, a session module, a token module holding the lease, and two server actions — and this server is your credential authority.
They are bricks, not a framework. Each one does a single job and none of
them knows what your app is for, so you can take all six or take the three
you are missing. examples/nextjs in the repository is the same
set assembled and running, if you would rather read it working:
cd examples/nextjs
pnpm install
cp .env.example .env.local # fill in the two secrets
pnpm db:push && pnpm db:seed && pnpm dev
Wiring this into a session library instead of hand-rolling
session.ts is a separate concern, and gets its own guide:
BetterAuth plugin.
BetterAuth plugin
The Next.js tab hand-rolls its session in six files and needs no library. This is the other answer: keep BetterAuth for the browser session — its cookie, its CSRF, its route handler, its React hooks — and make this server the credential authority behind it. Nothing about the split changes. Bastion tokens still never reach the browser; the plugin is only the seam between the two.
Browser ──BetterAuth cookie──> Next (Node) ──Bearer token──> this server
│
└── bastionCredential: sealed token pair
| Concern | Owner |
|---|---|
| Passwords, account records, refresh-token rotation | This server |
Session cookie, CSRF, /api/auth/* handler, client hooks | BetterAuth |
| Everything else | You |
A working copy is examples/nextjs/src/lib/bastion/ in the
repository — thirteen files, one dependency beyond BetterAuth itself
(zod), and exactly one line that knows where your database is.
Copy the directory; the rest of this section is what is in it and why.
Install
pnpm add better-auth zod
On pnpm 10, better-sqlite3 also needs its postinstall
unblocked, or the native binding is never compiled — and pin it to 12,
because BetterAuth peer-requires ^12:
// package.json
"pnpm": { "onlyBuiltDependencies": ["better-sqlite3"] }
That block is pnpm's. npm and Yarn run install scripts by default and need
nothing; Bun blocks them the same way pnpm does and reads
"trustedDependencies": ["better-sqlite3"] instead. Whichever
you use, the symptom of getting it wrong is identical — a missing
.node binding at the first database call.
| Variable | Default | Notes |
|---|---|---|
BASTION_URL | http://127.0.0.1:8080 | No trailing slash |
BASTION_API_PREFIX | /api/v1 | |
BASTION_TOKEN_SECRET | required | 32 bytes base64; seals tokens at rest |
BASTION_TIMEOUT_MS | 8000 | |
BASTION_REFRESH_SKEW_SECONDS | 30 | Raise it to force a refresh on every call while testing |
BASTION_REFRESH_LEASE_MS | 15000 | How long one refresh may hold the lock |
BASTION_FORWARD_CLIENT_IP | false | See Rate limits below |
Parse that at module load and throw on anything invalid. A missing token secret should stop the process at boot, not surface as a decrypt failure on somebody's first sign-in three hours later.
The config, and the one setting that must stay off
// src/lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { db, schema } from "@/db";
import { bastion } from "@/lib/bastion";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "sqlite", schema }),
secret: process.env.BETTER_AUTH_SECRET,
emailAndPassword: { enabled: false },
user: {
additionalFields: {
bastionUserId: { type: "string", required: false, input: false },
role: { type: "string", required: false, input: false, defaultValue: "user" },
},
},
// nextCookies() must be last — it wraps the response so Set-Cookie survives
// Next's server-action boundary.
plugins: [bastion(), nextCookies()],
});
emailAndPassword must stay disabled. Leaving
it on mounts /sign-up/email, /forget-password and
/reset-password, which write password hashes into BetterAuth's
own account table. This server would know nothing about them,
and a user who reset their password there would find it unchanged
everywhere else. Passwords get one owner.
input: false on both added fields is not decoration either.
Without it BetterAuth accepts them from a request body, and a browser can
hand itself role: "admin" or point its local user at somebody
else's account here. They are only ever written server-side, from claims.
What the plugin adds
| Route | Body | Calls out |
|---|---|---|
POST /api/auth/sign-in/bastion | {email, password} | 1 |
POST /api/auth/sign-up/bastion | {email, password} | 2 — register returns no tokens, so a login follows |
POST /api/auth/change-password/bastion | {currentPassword, newPassword} | 2 — see below |
POST /api/auth/sign-out | — | 1, from a before hook |
Sign-in is the shape all of them share: authenticate here, mirror the
identity into BetterAuth's user table, mint a BetterAuth
session, stash the token pair against that session id.
// src/lib/bastion/plugin.ts
import type { BetterAuthPlugin } from "better-auth";
import { APIError, createAuthEndpoint } from "better-auth/api";
import { setSessionCookie } from "better-auth/cookies";
import * as z from "zod";
export const bastion = () =>
({
id: "bastion",
schema: bastionSchema,
endpoints: {
signInBastion: createAuthEndpoint(
"/sign-in/bastion",
{ method: "POST", body: z.object({ email: z.string(), password: z.string() }) },
async (ctx) => {
const email = ctx.body.email.trim().toLowerCase();
try {
const issued = await api.login({ email, password: ctx.body.password });
const claims = decodeAccessTokenClaimsUnverified(issued.access_token);
const user = await upsertUser(ctx, {
bastionUserId: claims.sub, email, role: claims.role,
});
const session = await ctx.context.internalAdapter.createSession(user.id);
tokens.persist({ sessionId: session.id, bastionUserId: claims.sub, tokens: issued });
await setSessionCookie(ctx, { session, user });
return ctx.json({ token: session.token, user });
} catch (error) {
return toApiError(error); // maps 401/422/429 onto APIError
}
},
),
},
}) satisfies BetterAuthPlugin;
Two details in there are this server's, not BetterAuth's. The email is
normalised trim().toLowerCase() because that is what happens
here before both lookup and insert — skip it and one account here becomes
two local users. And the email comes from the form, not the token:
the access token carries sub, role,
exp, iat, jti and no email claim,
and there is no /me to ask.
upsertUser looks the local user up through the
account table on the uuid rather than matching on email — email
is mutable in principle, the uuid is not — and rewrites role on
every sign-in, so a promotion here takes effect at the user's next login
with no sync job anywhere.
Sign-out needs no endpoint of its own, just a hook that runs first:
hooks: {
before: [{
// Before, not after: once BetterAuth drops the session row there is no
// way back to the credential.
matcher: (context) => context.path === "/sign-out",
handler: createAuthMiddleware(async (ctx) => {
const current = await getSessionFromCtx(ctx);
if (current?.session) await tokens.revoke(current.session.id);
}),
}],
}
One credential row per session, not per user
BetterAuth's account table is the obvious home and the wrong
one: it is keyed by (user, provider), and this server mints a separate
refresh-token family per login. Two devices sharing a row would rotate each
other's token away, which reads as replay here and revokes the family.
// src/lib/bastion/schema.ts
export const bastionSchema = {
user: {
fields: {
bastionUserId: { type: "string", required: false, input: false },
role: { type: "string", required: false, input: false, defaultValue: "user" },
},
},
bastionCredential: {
fields: {
sessionId: {
type: "string", required: true, unique: true,
references: { model: "session", field: "id", onDelete: "cascade" },
},
bastionUserId: { type: "string", required: true },
accessToken: { type: "string", required: true }, // AES-256-GCM sealed
refreshToken: { type: "string", required: true }, // AES-256-GCM sealed
accessTokenExpiresAt: { type: "date", required: true },
generation: { type: "number", required: true, defaultValue: 0 },
lockedUntil: { type: "date", required: false },
status: { type: "string", required: true, defaultValue: "active" },
createdAt: { type: "date", required: true },
updatedAt: { type: "date", required: true },
},
},
} satisfies PluginDBSchema;
pnpm dlx @better-auth/cli generate
Seal both token columns yourself. BetterAuth's
encryptOAuthTokens only covers its account table,
so a refresh token sitting here in plaintext is a standing account takeover
for anyone who gets a copy of the file.
BetterAuthPlugin.
PluginDBSchema above is
NonNullable<BetterAuthPlugin["schema"]>, not an import
from @better-auth/core/db where it actually lives. That package
is a transitive dependency, and pnpm or Yarn PnP will not resolve it from
your app — which would cost the directory its one virtue, being
copy-pasteable.
The client half
Types only. Bastion tokens never reach the browser, so there is nothing for
a client plugin to do at runtime — it exists so
authClient.signIn.bastion(…) type-checks.
// src/lib/bastion/client.ts
import type { BetterAuthClientPlugin } from "better-auth";
import type { bastion } from "./plugin";
export const bastionClient = () =>
({ id: "bastion", $InferServerPlugin: {} as ReturnType<typeof bastion> })
satisfies BetterAuthClientPlugin;
// src/lib/auth-client.ts
"use client";
import { createAuthClient } from "better-auth/react";
import { bastionClient } from "./bastion/client";
export const authClient = createAuthClient({ plugins: [bastionClient()] });
export const { useSession, signOut } = authClient;
Calling it from a server action
Going through auth.api.* rather than the client keeps sign-in
to one round trip, which matters if anything else has to happen in the same
request — merging a guest cart, say.
// src/app/(auth)/actions.ts
"use server";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { APIError } from "better-auth/api";
import { auth } from "@/lib/auth";
export async function signInAction(_state: FormState, form: FormData) {
let userId: string;
try {
const result = await auth.api.signInBastion({
body: { email: String(form.get("email")), password: String(form.get("password")) },
headers: await headers(),
asResponse: false,
});
userId = result.user.id; // NOT getSession() — see below
} catch (error) {
return { error: error instanceof APIError ? error.message : "Something went wrong." };
}
await mergeGuestCart(userId);
redirect("/products");
}
setSessionCookie writes to the response; the request
headers this action was called with still describe an anonymous visitor, so
a following auth.api.getSession() reports nobody and any work
keyed on the user silently does nothing. Take the id from the endpoint's
return value. Also leave requireRequest off your endpoints —
it makes auth.api.* callers pass a request, which
server actions do not have.
Using a token in your own code
withAccessToken refreshes if the token is close to expiry and
retries once on an unexpected 401 — a clock skew, or a revocation that
landed between the expiry check and the call.
import { withAccessToken, CredentialRevoked } from "@/lib/bastion";
try {
await withAccessToken(session.id, (token) =>
fetch(`${base}/api/v1/notes`, { headers: { authorization: `Bearer ${token}` } }),
);
} catch (error) {
if (error instanceof CredentialRevoked) {
// sign the user out; this is not a 500
}
}
Turn the retry off where a 401 is not about the token.
/auth/password is the case: a wrong current password
answers 401 exactly like a stale token does, so the default rule spends a
rotation to rediscover the same 401.
await withAccessToken(session.id, (token) => api.changePassword(token, { … }),
{ retryOnUnauthorized: false });
That endpoint then has a second consequence: a successful password change revokes every session on the account here, including the one that made the call. The plugin logs back in with the new password and swaps the stored credential in place, so the BetterAuth session survives against a new token family. Doing nothing would leave the user holding a dead session.
bastionUserId, email and
role, so rendering a page needs no call to this server at all —
only auth transitions and the odd privileged action go out. Every
request from your app arrives from one IP and shares one rate-limit bucket;
a proactive background refresh would multiply that traffic by your user
count. Keep your session module structurally unable to reach the network.
The refresh lease
Same problem as the hand-rolled version, same solution, and worth repeating
because it is the one part you cannot simplify away. Refresh tokens are
single-use and losing the rotation race revokes the whole family,
so two concurrent refreshes sign the user out everywhere. A promise map in
module scope does not fix it: next dev compiles route handlers
in separate workers and production runs more than one instance.
The lock is a compare-and-swap on generation. The winner is
whoever changes exactly one row.
// src/lib/bastion/store.ts
export function acquireLease(input: {
sessionId: string; observedGeneration: number; leaseMs: number;
}): boolean {
const now = Date.now();
const result = sqlite.prepare(
`UPDATE "bastionCredential"
SET lockedUntil = ?, updatedAt = ?
WHERE sessionId = ?
AND generation = ?
AND status = 'active'
AND (lockedUntil IS NULL OR lockedUntil < ?)`,
).run(now + input.leaseMs, now, input.sessionId, input.observedGeneration, now);
return result.changes === 1;
}
A loser polls until the generation moves, bounded by the lease so a crashed
holder cannot block anyone forever. Committing a rotation bumps
generation, which releases the lease and invalidates every
other holder's compare-and-swap in the same statement. The in-process
promise map on top of this is an optimisation — delete it and you are
slower but still correct; delete the lease and you are neither.
Failures split three ways, and collapsing them is how sessions die:
| Outcome | Token | Do |
|---|---|---|
| 429 | Unspent — the limiter rejected it before the handler | Release the lease, let the next caller try |
| 401 | Definitively dead — replayed, logged out, password changed | Mark revoked, sign the user out |
| Timeout / 5xx | Unknown — may have been consumed with the response lost | Mark poisoned, never retry |
poisoned costs one session. Retrying a token that was in fact
consumed looks like replay and costs every session in the family, which is
the trade that rule exists to make.
Rate limits
The /auth/* bucket here defaults to 5/s, burst 5, and your
whole app shares it. Pace your own calls just under that — 4/s — so your
client-side bucket empties first and a burst becomes a short wait instead of
a 429. For per-end-user buckets set
BASTION_FORWARD_CLIENT_IP=true, but only when this server runs
behind a proxy you control with APP_TRUST_PROXY_HEADERS=true;
otherwise the header is either ignored or spoofable.
Copying it
Take examples/nextjs/src/lib/bastion/ whole. Three edits and it
runs:
-
store.ts—import { sqlite } from "@/db"is the only line that knows where your database lives. -
Your BetterAuth config — add
bastion()and turnemailAndPasswordoff. - Your auth client — add
bastionClient().
Then pnpm dlx @better-auth/cli generate for the tables. The
example runs against Next 16.2 and better-auth 1.6.25:
cd examples/nextjs
pnpm install
cp .env.example .env.local # fill in the two secrets
pnpm db:push && pnpm db:seed && pnpm dev
CSS frameworks
Anything that compiles to a stylesheet works, because the server only serves files. The one rule: the output must be a file, not injected inline, since the page CSP does not permit inline styles.
Nothing to configure. A stylesheet in the served directory, linked normally.
<link rel="stylesheet" href="/style.css">
This page is exactly that: one hand-written stylesheet, no build step, no
framework. Its source is bastion/style.css in the repository.
pnpm add -D tailwindcss @tailwindcss/vite
// vite.config.ts
import tailwindcss from "@tailwindcss/vite";
export default { plugins: [tailwindcss()] };
/* main.css */
@import "tailwindcss";
Vite emits a hashed stylesheet into dist/assets/ and links it
from the built index.html. Serve dist/ and it
works — the CSP is satisfied because the CSS is a file on the same origin.
pnpm add -D sass
Vite compiles .scss with no further configuration. Import it
from your entry module and the build emits a plain stylesheet:
import "./styles/main.scss";
Endpoints
Base path /api/v1, except the probes.
| Method | Path | Auth | Body |
|---|---|---|---|
| GET | /health/live | — | — |
| GET | /health/ready | — | — |
| POST | /api/v1/auth/register | — | {email, password} |
| POST | /api/v1/auth/login | — | {email, password} |
| POST | /api/v1/auth/refresh | — | {refresh_token} |
| POST | /api/v1/auth/logout | — | {refresh_token} |
| POST | /api/v1/auth/password | Bearer | {current_password, new_password} |
| GET / POST | /api/v1/notes | Bearer | {title, body} |
| GET / PUT / DELETE | /api/v1/notes/{id} | Bearer | {title, body} |
| DELETE | /api/v1/admin/users/{id}/sessions | Bearer (admin) | — |
Errors
One shape everywhere, so clients branch on code rather than prose.
{
"error": {
"code": "validation_failed",
"message": "request validation failed",
"details": [ { "field": "email", "message": "must be a valid email address" } ]
}
}
| Status | code | What your UI should do |
|---|---|---|
| 400 | validation_failed | Show details against the named fields |
| 401 | unauthorized | Refresh once; if that fails, show the login screen |
| 403 | forbidden | Hide the action — the user is not permitted |
| 404 | not_found | Treat as absent; it may exist but not be yours |
| 409 | conflict | Email already registered |
| 429 | — | Back off; do not retry in a tight loop |
| 503 | service_unavailable | Server shedding load; retry with backoff |
CSP & headers
The server sends two different policies, because an API and a web page need different things and merging them weakens both.
| API routes | Served pages | |
|---|---|---|
| Content-Security-Policy | default-src 'none'; sandbox |
default-src 'self', no inline |
| Cache-Control | no-store | public, max-age=3600 |
Both get the same baseline on every response:
strict-transport-security: max-age=31536000; includeSubDomains; preload
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: no-referrer
cross-origin-opener-policy: same-origin
cross-origin-resource-policy: same-origin
permissions-policy: camera=(), geolocation=(), microphone=(), payment=(), usb=()
x-request-id: <uuid, echoed back for correlation>
What this means for your frontend
- No inline scripts or styles. No
<script>alert(1)</script>, nostyle="…"attributes, noonclick=handlers. Put code in files and attach listeners in JavaScript. Every bundler already does this. - Same-origin only. No CDN scripts, fonts, or images. Install the dependency and let your bundler emit it locally.
- The document revalidates, assets are cached.
index.htmlis servedno-cacheso a deploy takes effect immediately; everything else gets an hour. Cache the document instead and users keep running the previous build until it expires. - Tokens live in memory, refresh tokens in
localStorage. With inline execution blocked andframe-ancestors 'none'set, the usual paths to stealing them are closed.
If a framework insists on inline styles, prefer configuring it to emit a file
over relaxing the policy. 'unsafe-inline' removes most of what
CSP is protecting you from.