Admin auth
Operator identity for the admin console - a separate admin schema, scrypt password hashing, and SHA-256-hashed session tokens.
The admin console is operated by platform staff, not tenants. Its identity is deliberately separate from tenant users: admin identities live in their own admin schema, away from Better Auth and the tenant tables. @africaos/admin-auth owns this entirely.
Why a separate identity system
Tenant authentication uses Better Auth (email/password, Google OAuth, WorkOS) and its own tables. The admin console needs a small, staff-only login that:
- Never touches tenant tables or the Better Auth provider.
- Uses its own credential store in an
adminschema. - Issues opaque session tokens that the admin app stores in an HttpOnly cookie.
Keeping the two identity systems separate means operator access cannot leak into tenant flows, and tenant users can never authenticate as staff.
The schema
Migration 0009_admin.sql (plus a waitlist-emails addition) creates two tables:
| Table | Holds |
|---|---|
admin.admins |
id, name, password_hash, display_name, timestamps. |
admin.sessions |
id, admin_id, token_hash, expires_at. |
The database only ever stores hashes - never passwords or raw tokens.
Password handling
platform/admin-auth/src/password.ts hashes passwords with scrypt in the format scrypt$<salt_hex>$<hash_hex>:
export const hashPassword = async (password: string): Promise<string> => {
const salt = randomBytes(SALT_BYTES);
const derivedKey = await scryptAsync(password, salt, SCRYPT_KEYLEN);
return `scrypt$${salt.toString("hex")}$${derivedKey.toString("hex")}`;
};
Verification uses timingSafeEqual to avoid timing attacks, and returns false for malformed stored hashes rather than throwing, so a bad row behaves exactly like a wrong password.
Session tokens
Session tokens are opaque random values (32 random bytes, hex-encoded). Only their SHA-256 hash is persisted:
createSessionToken()- the raw value handed to the browser.hashSessionToken(token)- the SHA-256 hash stored inadmin.sessionsand used for lookup.
A leaked database never leaks a usable session, because the raw token exists only in the browser’s HttpOnly cookie.
AdminAuthService
platform/admin-auth/src/service.ts defines the service. Sessions last SESSION_TTL_MS (7 days):
| Method | Purpose | Fails with |
|---|---|---|
seed |
Create an admin, or refresh password/display name if the name exists. | AdminAuthFailure |
login |
Verify credentials and issue a session token. | InvalidCredentials, AdminAuthFailure |
logout |
Revoke a session by its raw token. | AdminAuthFailure |
getSession |
Resolve the admin behind a token; deletes expired sessions. | - |
stats |
Count admins and active sessions in one round trip. | - |
login looks up the admin by name, verifies the scrypt hash, then inserts a session row storing only the token hash. getSession hashes the incoming token, looks it up, and deletes the row when expired.
Seeding an admin
pnpm db:seed:admin runs infra/database/scripts/seed-admin.ts, which calls AdminAuthService.seed. Run it with credentials for the staff account you want:
pnpm db:seed:admin
Next steps
- Admin console - the app that uses this service.