auth-basic
| Package | @theoven/auth-basic |
| Adds to context | ctx.user, via @theoven/auth |
| Endpoints | eight, mounted at /auth/* |
| Creates files | none directly; its schema feeds your migrations |
| Creates tables | auth_users, auth_refresh_tokens, auth_reset_tokens |
| Status | shipped |
Install
Section titled “Install”bun add @theoven/auth @theoven/auth-basic @theoven/db @theoven/db-drizzleimport { auth } from '@theoven/auth'import { basicAuth } from '@theoven/auth-basic'
export const app = createApp() .use(db(drizzleSqlite({ url: './data.db', schema }))) .use(auth(basicAuth({ db: client, secret: env.string('AUTH_SECRET') })))That is the Laravel/Rails property: a working signup, login and password reset before you have provisioned anything. SQLite is a file, and reset emails print to your terminal until you configure a provider.
What it does
Section titled “What it does”Email-and-password authentication, stored with Drizzle over SQLite. It mounts eight endpoints at
/auth/*, hashes with argon2id, and issues a short-lived access JWT plus a revocable refresh
token — so logout, “sign out everywhere” and “changing a password ends other sessions” are all
real rather than client-side gestures (D20).
This is the default for oven create: signup, login and password reset work before you have
provisioned anything.
Endpoints
Section titled “Endpoints”| Method | Path | Purpose | Auth |
|---|---|---|---|
POST |
/auth/signup |
create an account | none |
POST |
/auth/login |
sign in | none |
POST |
/auth/refresh |
new access token from the refresh cookie | cookie |
POST |
/auth/logout |
revoke the session | cookie |
POST |
/auth/forgot-password |
email a reset link | none |
POST |
/auth/reset-password |
redeem a reset token | token |
POST |
/auth/change-password |
change while signed in | bearer |
GET |
/auth/me |
the current user | bearer |
The prefix is configurable: auth(basicAuth(...), { prefix: '/identity' }).
What it creates
Section titled “What it creates”Three tables, which your migrations must include:
export { users, refreshTokens, resetTokens } from '@theoven/auth-basic'| Table | Holds |
|---|---|
auth_users |
id, email (unique), name, argon2id hash, timestamps |
auth_refresh_tokens |
hashed refresh tokens, per user, with expiry |
auth_reset_tokens |
hashed single-use reset tokens, with expiry |
Refresh and reset tokens are stored hashed, so a leaked database does not hand over working credentials. Both cascade on user delete, so removing a user cannot leave sessions that outlive them.
Social sign-in
Section titled “Social sign-in”Google and GitHub are optional flows on this brick. Two extra steps beyond the configuration on the
auth page:
1. Add the accounts table to your schema. It lives on a separate export path, so an application that never uses social sign-in never gets it:
export * from '@theoven/auth-basic/schema'export * from '@theoven/auth-basic/schema/accounts'oven db generate && oven db migrate2. Configure a provider. That is the opt-in — it turns on the store’s account methods, and the brick then checks at boot that you did step 1 too.
What it creates
Section titled “What it creates”| Table | Holds |
|---|---|
auth_accounts |
id, user, provider, the provider’s account id, and optionally its tokens |
Unique on (provider, provider_account_id), so the database — not the application — is what
prevents two racing callbacks linking the same provider account to two users. Cascades on user
delete, so removing a user does not leave credentials pointing at an id that no longer exists.
How sessions work
Section titled “How sessions work”A 15-minute access JWT plus a revocable refresh token:
POST /auth/login → access JWT (body) + refresh token (httpOnly cookie)POST /auth/refresh → new pair; the old refresh token stops workingPOST /auth/logout → refresh row deletedIdentifying a request is a signature check with no database read. Revocation lives with the refresh token, which is what makes logout real: delete the row and the access token expires within its window.
The refresh token is an httpOnly cookie so a cross-site script cannot read it; the short-lived
access token goes in the body, where a client attaches it to Authorization.
Configuration
Section titled “Configuration”| Option | Default | Purpose |
|---|---|---|
db |
— | your Drizzle client |
secret |
— | required; signs access tokens |
accessTtl |
900 |
access-token seconds |
refreshTtl |
2592000 |
refresh-token seconds (30 days) |
resetTtl |
3600 |
reset-link seconds |
minPasswordLength |
8 |
minimum password length |
sendResetEmail |
— | how reset links are delivered |
rateLimit |
on | throttles login, signup and reset; false to disable |
refreshCookie |
oven_refresh |
cookie name |
There is no default secret, and the brick refuses to construct without one. A framework that
invents a signing secret has invented a secret every deployment shares.
Rate limiting
Section titled “Rate limiting”Login, signup and password-reset are the endpoints that actually get attacked, so they are throttled by default. Nothing to add:
| Endpoint | Default | Keyed by |
|---|---|---|
POST /auth/login |
10 per 15 min | IP and email |
POST /auth/signup |
5 per 15 min | IP |
POST /auth/forgot-password |
3 per 15 min | IP and email |
Over the limit, the request gets 429 with a Retry-After header — as
problem+json, like every other Oven error.
Login and reset count against both the caller’s IP and the email in the body. Either key alone leaves a hole: limiting by IP does nothing against a distributed attempt on one account, and limiting by email lets a single host spray the whole user table.
basicAuth({ db: client, secret: env.string('AUTH_SECRET'), rateLimit: { login: 20, signup: 10, forgotPassword: 5, window: 5 * 60 * 1000 },})Sending reset emails
Section titled “Sending reset emails”basicAuth({ db: client, secret: env.string('AUTH_SECRET'), sendResetEmail: async (to, token) => { await mailer.send({ to, subject: 'Reset your password', text: `Open ${env.string('APP_URL')}/reset?token=${token}`, }) },})Omit it and reset tokens are simply not delivered — the flow still works end to end in tests and in development, where you can read the token from the store.
Security decisions
Section titled “Security decisions”These are the ones worth knowing about, because they shape what the endpoints return:
- Login does not reveal which emails exist. A wrong password and an unknown address get an identical body, an identical status, and comparable timing — an unknown email still pays an argon2 verification against a decoy hash.
- Forgot-password always succeeds. Reporting “no such account” would make it an enumeration endpoint. The person who owns the address finds out by email.
- Refresh tokens rotate. Using one invalidates it. A refresh token that survives its own use is one a thief keeps using alongside the real client.
- Reset tokens are single-use and expiring, and redeeming one signs every session out.
- Changing a password signs every other session out. Someone changing a password usually believes an account is compromised.
Each of these has a test, including the enumeration and token-reuse cases.
The endpoints are mounted for you. Your own routes just read ctx.user:
export const auth = true
export default async ({ user }) => ({ id: user.id, email: user.email, name: user.name })user.raw is the stored row, so any column you added to auth_users is reachable — and is what a
policy reads:
auth(basicAuth({ db: client, secret: env.string('AUTH_SECRET') }), { policies: { admin: (user) => user.raw.role === 'admin' },})A client, end to end
Section titled “A client, end to end”const { tokens } = await fetch('/auth/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }),}).then((r) => r.json())
// The access token goes in the header; the refresh token was set as an httpOnly cookie// and is never visible to your JavaScript, which is the point.await fetch('/me', { headers: { authorization: `Bearer ${tokens.accessToken}` } })
// When it expires — the cookie is sent automatically.await fetch('/auth/refresh', { method: 'POST' })Calling the flows from your own endpoint
Section titled “Calling the flows from your own endpoint”When signup needs to do more than create a user:
import { signup } from '@theoven/auth'import { z } from 'zod'
export const body = z.object({ email: z.email(), password: z.string().min(8), name: z.string(), company: z.string(),})
export default async ({ body, auth, db }) => { const { user, tokens } = await signup(auth.flows, body) await db.insert(companies).values({ name: body.company, ownerId: user.id }) return { user, ...tokens }}ctx.auth.flows is the same FlowConfig the mounted endpoints use, so password rules, hashing and
token lifetimes cannot drift from them.
Capabilities
Section titled “Capabilities”identify |
✓ — from the access JWT, no database read on the hot path |
routes |
✓ — the eight endpoints above |
signOut |
✓ — deletes the refresh row, so logout is real |
refresh |
✓ — rotates the refresh token |
| Email verification | ✗ — the column exists and is never set |
| OAuth / social login | ✗ — use auth-clerk or auth-better |
| Rate limiting shared across instances | ✗ — per process |
What fails at boot: a missing secret, and a missing db. A missing sendResetEmail only
warns, so reset works in development before mail is configured.
How it is verified
Section titled “How it is verified”Against a real SQLite database, through a real app, over HTTP — not against the store directly.
It runs the shared describeAuthStore conformance suite from @theoven/auth/testing, the same one
auth-mongo runs, plus the full flow: signup, login, refresh, logout,
change password, forgot, reset.
The assertions that matter are the negative ones. A wrong password and an unknown email fail with the same message and in comparable time, so the endpoint cannot be used to discover who has an account. A rotated refresh token stops working. A reset token cannot be redeemed twice. Changing a password ends every other session. The password is never stored — asserted by reading the row back and checking the plaintext appears nowhere in it.
A test also asserts this brick’s endpoint list matches auth-mongo’s exactly, which is what keeps
“same brick, different storage” true as both change.
Where the code lives
Section titled “Where the code lives”Almost none of this package is in this package. The flows, the eight endpoints, the cookie
handling, the rate limits and every piece of cryptography live in @theoven/auth, shared with
auth-mongo and any other storage brick. What is here is
schema.ts and store.ts — the Drizzle half, and nothing security-critical.
That split is the reason auth-mongo is a hundred lines rather than a fork. Two auth bricks with
their own copies of the token logic is a fix landing in one and not the other, which is the bug
nobody finds.
Limitations
Section titled “Limitations”- SQLite schema only. The tables are declared with
drizzle-orm/sqlite-core. Postgres works as a database, but you would restate the schema withpg-coretoday. - No email verification.
auth_users.email_verified_atexists and is never set. - Rate limiting is per process, not shared across instances — see the caveat above.
- No OAuth. Use
auth-clerkorauth-better— both ship, and both satisfy the same contract, soauth: trueand your policies keep working.