---
title: Admin auth
description: 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>`:

```ts
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:

```bash
pnpm db:seed:admin
```

### Next steps

- [Admin console](/platform/admin) - the app that uses this service.