Packages
@thexjs/auth
Plug-and-play authentication for X apps. Add credentials (username/password) and OAuth2, including a preconfigured GitHub provider, with one defineAuth() call, a sessions table in SQLite or Postgres via the framework's data layer, and a single catch-all API route.
bun add @thexjs/auth
Quick start
Define your providers and session store once:
import { defineAuth, createSQLiteSessionStore, hashPassword, verifyPassword } from "@thexjs/auth";export const auth = defineAuth({ secret: process.env.AUTH_SECRET!, store: createSQLiteSessionStore(), // or createPostgresSessionStore(client) providers: [ { id: "local", name: "Local", type: "credentials", async authorize({ email, password }) { const user = await db.query("SELECT * FROM users WHERE email = ?").get(email); if (!user) return null; if (!(await verifyPassword(password, user.password_hash))) return null; return { id: String(user.id), name: user.name, email: user.email }; }, }, { id: "github", name: "GitHub", type: "oauth", clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, }, ],});The github provider is a preset: give it a client ID and secret and the authorization, token, and user-info URLs are wired up for you. A generic OAuth2 provider is available too for any other authorization-code provider.
Wire it up with one catch-all API route:
import { auth } from "../../lib/auth";export async function POST(req: Request) { return auth.handleRequest(req);}export async function GET(req: Request) { return auth.handleRequest(req);}Create users at sign-up with hashPassword (Argon2id) and store the hash, never the plaintext:
import { hashPassword } from "@thexjs/auth";await hashPassword("correct horse battery staple");Endpoints
handleRequest routes the path below api/auth:
Route Method Purpose──────────────────────────────────────────────────────────────/api/auth/signin/<id> POST credentials provider: form or multipart body with the provider's fields (e.g. email, password)/api/auth/signin/<id> GET OAuth2 provider: redirects the browser to the provider's authorization URL/api/auth/callback/<id> GET OAuth2 callback: exchanges the code, validates the state challenge, signs the user in/api/auth/signout POST revokes the session and clears the cookie/api/auth/session GET JSON { "user": { ... } } or 401A sign-in form POSTs to /api/auth/signin/localand, after success, the browser follows the 302 to successRedirect (default /). For OAuth, the button or link is just a GET to /api/auth/signin/github.
Reading the session
const session = await auth.getSession(request);if (!session) return new Response("Unauthorized", { status: 401 });session.user; // { id, name?, email? } snapshot from sign-ingetSession hashes the x_session cookie, looks up the token in the store, and returns null for expired or revoked sessions. For programmatic flows, the defineAuth() result also exposes setSessionCookie(res, user, provider) and clearSessionCookie(res, req?).
Security
- Passwords are Argon2id via Bun.password ( hashPassword / verifyPassword).
- Session tokens are opaque random strings; only an HMAC-SHA256 digest (keyed by secret) is stored, so a database leak doesn't expose usable session cookies. Tokens are random 128-bit values, revocable, and expire after sessionMaxAge (default 7 days).
- OAuth state: an x_oauth_state cookie challenge must match the state param on the callback (HMAC'd, 5-minute expiry), preventing login-CSRF and session-fixation via crafted callbacks.
- CSRF: POST endpoints ( signin, signout ) run the core checkCsrf automatically: Origin/Referer verification by default, or requireToken for double-submit defense in depth, and it rejects non-conforming requests with 403. See Security for how the module is configured.
- Cookies are HttpOnly, SameSite=Lax, Secure in production.
Set a stable secret in production. If omitted, a random per-process secret is generated and a warning is printed, which means sessions won't survive restarts.
Session stores
Both stores use a single x_sessions table and implement the SessionStore interface ( create, find, revoke), so you can bring your own:
createSQLiteSessionStore({ path: "data/auth.db" }); // default: data/auth.dbcreatePostgresSessionStore(connectPostgres({ url: process.env.DATABASE_URL }));The Postgres store ensures the table lazily on first use and takes a client returned by connectPostgres from @thexjs/core/data, so it inherits the connection pool, TLS policy, and retry behavior of the framework. See Data Layer for the underlying stores.
Route guards
The auth object turns session checks into framework middleware. auth.requireAuth(), auth.requireRole(), and auth.requirePermission() each return a MiddlewareFn: failing checks short-circuit with a 401/403, or a 302 when you pass redirectTo. Drop them in a _middleware.ts to guard everything in a folder:
import { auth } from "../../lib/auth";export const middleware = auth.requireRole("admin", { redirectTo: "/signin",});Denied-but-signed-in attempts are written to the audit trail automatically, so permission failures are reviewable after the fact.
Brute-force protection
createBruteForceGuard tracks failed sign-ins in two separate buckets: one per account identifier, one per client IP (so one IP spraying many accounts locks out fast). Defaults: 5 attempts per 15-minute window.
import { createBruteForceGuard } from "@thexjs/auth";const guard = createBruteForceGuard({ maxAttempts: 5, windowMs: 15 * 60_000 });// before verifying a password — check the account bucket and the IP bucket:const account = guard.accountKey(email);if (!guard.status(account).ok || !guard.status(guard.ipKey(req)).ok) { return Response.json({ error: "Too many attempts" }, { status: 429 });}// after a failed verification:guard.recordFailure(account);