Skip to content

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
Terminal window
bun add @theoven/auth @theoven/auth-basic @theoven/db @theoven/db-drizzle
src/app.ts
import { 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.

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.

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' }).

Three tables, which your migrations must include:

src/schema.ts
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.

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:

src/schema.ts
export * from '@theoven/auth-basic/schema'
export * from '@theoven/auth-basic/schema/accounts'
Terminal window
oven db generate && oven db migrate

2. 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.

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.

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 working
POST /auth/logout → refresh row deleted

Identifying 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.

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.

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 },
})
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.

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:

src/routes/me.get.ts
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:

src/app.ts
auth(basicAuth({ db: client, secret: env.string('AUTH_SECRET') }), {
policies: { admin: (user) => user.raw.role === 'admin' },
})
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' })

When signup needs to do more than create a user:

src/routes/register.post.ts
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.

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.

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.

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.

  • SQLite schema only. The tables are declared with drizzle-orm/sqlite-core. Postgres works as a database, but you would restate the schema with pg-core today.
  • No email verification. auth_users.email_verified_at exists and is never set.
  • Rate limiting is per process, not shared across instances — see the caveat above.
  • No OAuth. Use auth-clerk or auth-better — both ship, and both satisfy the same contract, so auth: true and your policies keep working.