Authentication
Backroad ships an optional auth wrapper around
better-auth. Pass a configured better-auth
instance to run(...) and you get:
- A typed
br.userfield on every script execution that tells you whether the connection is logged in. br.login()/br.logout()helpers that drive the browser to the auth pages.- A pre-built React sign-in / sign-up UI mounted at
/auth/signinand/auth/signup, themed alongside the rest of the app.
Installβ
The auth pieces are an optional peer dependency so consumers who don't need them pay nothing.
pnpm add better-auth
# Whatever DB adapter you want β better-sqlite3 for local dev, the
# postgres/mysql/etc. adapter for prod. Memory works in tests.
pnpm add better-sqlite3
Wire it upβ
import { run } from '@backroad/backroad';
import { betterAuth } from 'better-auth';
import Database from 'better-sqlite3';
const auth = betterAuth({
database: new Database('./auth.sqlite'),
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.BETTER_AUTH_URL ?? 'http://localhost:3333',
emailAndPassword: {
enabled: true,
autoSignIn: true,
},
// Add socialProviders / passkeys / 2FA per better-auth docs.
});
run(
(br) => {
if (!br.user.isLoggedIn) {
br.write({ body: '## Please log in to continue' });
const click = br.button({ label: 'Log in' });
if (click) br.login();
return;
}
br.write({ body: `Hello, **${br.user.name}** π` });
if (br.button({ label: 'Log out' })) {
br.logout();
return;
}
// β¦rest of the gated app
},
{ auth: { instance: auth } }
);
That's it. Visiting / shows the gate; clicking Log in redirects to
/auth/signin (the React UI from @daveyplate/better-auth-ui); after a
successful sign-up or sign-in the browser hard-reloads back to / and
br.user.isLoggedIn is now true.
What lives whereβ
| URL | Renders |
|---|---|
/auth/signin | Email + password sign-in form |
/auth/signup | Sign-up form (name + email + password) |
/auth/forgot-password | Password reset request |
/api/auth/* | better-auth's REST endpoints β used by the React forms |
br.user shapeβ
type BackroadUser =
| { isLoggedIn: false }
| {
isLoggedIn: true;
id: string;
name: string;
email: string;
image?: string;
raw: unknown; // the full better-auth session record
};
Always check isLoggedIn before reading the user fields β TypeScript will
make you.
Logging outβ
br.logout();
This emits an auth_signout event over the socket. The React client posts
to /api/auth/sign-out (clears the cookie) and navigates to /auth/signin.
Session lifecycle β what the framework actually doesβ
- Browser sends an HTTP upgrade request to open the WebSocket. The cookie travels in the headers.
- On connection open, Backroad calls
auth.api.getSession({ headers: fromNodeHeaders(req.headers) })once and caches the result on the session object. - Every subsequent script execution on that connection reads
br.userfrom the cache. - Logging out destroys the cookie and forces a page reload β a fresh
WebSocket opens, gets re-resolved against the now-cleared cookie, and
sees
isLoggedIn: false.
The single per-connection resolution is a v1 simplification. If a user signs out in another tab, this connection keeps its cached user until it reconnects.
Adding social providersβ
better-auth's social providers work transparently β register them on the
instance and call br.login('google') to deep-link to the OAuth flow:
const auth = betterAuth({
// β¦ database, secret, baseURL
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
});
// In your run() callback:
if (br.button({ label: 'Sign in with Google' })) {
br.login('google');
}
The button in the /auth/signin UI is wired automatically based on the
providers you registered.
When not to use itβ
- For a single-user internal tool behind your VPN or Cloudflare Access, skip the in-app auth and let your network layer gate access.
- For multi-tenant SaaS with per-org RBAC, your app probably needs more
than Backroad's session resolution β use better-auth directly and roll
your own RBAC layer;
br.user.rawexposes the full session record.