Skip to content
Africa OS Knowledgebase
Esc
navigateopen⌘Jpreview
On this page

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 admin schema.
  • 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 in admin.sessions and 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

Last updated on August 18, 2026