PreferencesUser
Game Verse

KISS Admin Auth Pattern (Next + Convex)

Minimal admin authentication, safe bootstrap, lock /dashboard area, super admin rights

Objectives

  • KISS: no heavy auth libraries; easy to port to other projects.
  • Admin login possible, session created via HMAC-signed HttpOnly cookie.
  • Safe bootstrap mechanism: if database has no admin, allow first admin creation on login.
  • Lock entire /dashboard/** for logged-in admins only.
  • Super admin rights via ADMIN_EMAIL environment variable:
    • Only super admin can create/delete other admins.
    • Cannot deactivate super admin.

Component Diagram

  • FE (Next App Router):

    • Login page /sign-in (form POSTs to login API).
    • Middleware for /dashboard/** checks session cookie (adminSession).
    • "Logout" Dropdown → calls logout API.
    • Admin/Member CRUD pages call server API to hash passwords and call Convex.
  • API server (Next Route Handlers):

    • POST /api/admin/login: login, bootstrap first admin when DB is empty.
    • POST /api/admin/logout: delete session cookie.
    • GET /api/admin/me: return session info (isSuper,…).
    • POST /api/admin/set-password: create admin (super admin only; except when DB empty).
    • POST /api/admin/change-password: change admin password (requires login).
    • POST /api/admin/delete: delete admin (super admin only, cannot delete super admin).
    • POST /api/members/create: create member (requires login).
    • POST /api/members/change-password: change member password (requires login).
  • Convex backend:

    • admins.ts module: create, update, changePassword, toggleActive, listBrief, getByUsernameWithHash, deleteById.
    • members.ts module: has create, updateProfile, changePassword, toggleActive, listBrief.

Password Format (server-side hash)

  • PBKDF2 hashing on server (API route), not on client.
  • Stored in DB as string: pbkdf2$<iterations>$<saltB64>$<hashB64>.
  • On login: split string, run PBKDF2 on entered password → compare with hashB64.

Example hash (shortened, used in route):

// Create hash
const iter = 100_000
const salt = crypto.randomBytes(16)
crypto.pbkdf2(password, salt, iter, 32, 'sha256', (_e, derived) => {
  const passwordHash = `pbkdf2$${iter}$${salt.toString('base64')}$${derived.toString('base64')}`
})

// Verify
const [_, iterStr, saltB64, hashB64] = passwordHash.split('$')
crypto.pbkdf2(password, Buffer.from(saltB64, 'base64'), Number(iterStr), 32, 'sha256', (_e, derived) => {
  const ok = derived.toString('base64') === hashB64
})
  • HMAC-SHA256 self-signed (no external JWT) for minimal payload:
    • Payload: {"sub":"<adminId>","username":"<email>","name":"<name>","exp":<epoch_seconds>} (base64url encoded).
    • Token: <payload_b64url>.<sig_b64url>.
    • Signed with AUTH_SECRET (or NEXTAUTH_SECRET if shared).
  • Store adminSession cookie with properties:
    • httpOnly: true, sameSite: 'lax'.
    • secure: process.env.NODE_ENV === 'production' (Vercel compatible).
    • maxAge: 8 hours.

Login + Admin Bootstrap Flow

  1. User submits POST /api/admin/login with username, password.
  2. Server checks:
    • If DB has no admin:
      • If ADMIN_EMAIL and ADMIN_PASSWORD are set in env, only bootstrap if match.
      • If not set, accept current login pair as first admin.
      • Create admin record with PBKDF2 hash then proceed to issue session.
    • If DB has admin: authenticate normally.
  3. Return adminSession cookie if successful.

Protecting /dashboard/**

  • Middleware reads adminSession cookie, verifies HMAC and exp.
  • Invalid → redirect /sign-in?next=/dashboard/....
  • Suggestion: dashboard layout can check further to prevent UI flicker.

Super Admin Rights

  • Identify super admin via env ADMIN_EMAIL.
  • Only super admin can call sensitive operations:
    • POST /api/admin/set-password (create new admin) — exception: DB empty.
    • POST /api/admin/delete (delete admin): cannot delete super admin.
  • UI can hide/gray buttons based on GET /api/admin/me ({ ok, username, isSuper, superUsername }).

Environment Variables

AUTH_SECRET=...              # HMAC session cookie signing key
NEXT_PUBLIC_CONVEX_URL=...   # Convex URL
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=changeme      # only for first bootstrap; can delete after admin exists

Files & Locations (reference sample project)

  • FE routes & middleware:

    • apps/web/src/app/sign-in/page.tsx
    • apps/web/src/middleware.ts
    • apps/web/src/components/profile-dropdown.tsx (Logout)
    • apps/web/src/components/layout/data/sidebar-data.ts (add Admin/Member links)
  • API routes:

    • apps/web/src/app/api/admin/login/route.ts
    • apps/web/src/app/api/admin/logout/route.ts
    • apps/web/src/app/api/admin/me/route.ts
    • apps/web/src/app/api/admin/set-password/route.ts
    • apps/web/src/app/api/admin/change-password/route.ts
    • apps/web/src/app/api/admin/delete/route.ts
    • apps/web/src/app/api/members/create/route.ts
    • apps/web/src/app/api/members/change-password/route.ts
  • Session utils:

    • apps/web/src/lib/session.ts (HMAC sign/verify, base64url, exp)
  • Convex backend:

    • packages/backend/convex/admins.ts
    • packages/backend/convex/members.ts (existing)

Quick Deployment Checklist for Other Projects

  1. Create admins table in Convex schema (with by_username, by_active indexes).
  2. Add Convex module admins.ts with functions:
    • create, update, changePassword, toggleActive, listBrief, getByUsernameWithHash, deleteById.
  3. Add lib/session.ts to sign/verify HMAC.
  4. Add API routes for login, create/delete/change password for admins, create/change password for members.
  5. Add middleware to block /dashboard/**.
  6. Create /sign-in page + "Logout" button.
  7. (Optional) Hide sensitive UI buttons when not super admin (use /api/admin/me).
  8. Set env: AUTH_SECRET, NEXT_PUBLIC_CONVEX_URL, ADMIN_EMAIL, ADMIN_PASSWORD (bootstrap).

Security & Operations Suggestions

  • Always use HTTPS (Vercel production sets secure=true via NODE_ENV=production).
  • Can add rate-limit to /api/admin/login.
  • Add "don't disable last active admin" in backend if using multi-admin model.
  • After successful bootstrap, can delete ADMIN_PASSWORD from env.

FAQ

  • "Can NextAuth be used instead of this mechanism?"

    • Yes. This pattern chooses KISS, no external provider dependency. With NextAuth, still can reuse Convex for data.
  • "Do we need refresh tokens?"

    • No. Session cookie has short exp (8h); when expired user logs in again.
  • "Do we need to encrypt cookies?"

    • Cookie is HttpOnly and HMAC-signed; payload contains no sensitive info (only sub, username, name, exp).

Technical Details (deep dive)

HMAC token — create and verify

  • Create token:
    • Step 1: generate JSON payload: {"sub":"<id>","username":"<email>","name":"<name>","exp":<epoch_seconds>}.
    • Step 2: base64url payload → payload_b64 (no =; +-, /_).
    • Step 3: HMAC-SHA256 sign: sig = HMAC_SHA256(AUTH_SECRET, payload_b64) → base64url sig_b64.
    • Step 4: final token: payload_b64.sig_b64.
  • Verify token (middleware/API):
    • Step 1: split token by dot into payload_b64 and sig_b64.
    • Step 2: verify HMAC with AUTH_SECRET using environment's crypto API.
    • Step 3: parse payload (base64 decode) → check exp greater than "now" (epoch seconds).
    • If both conditions pass → valid.

Why not use JWT library?

  • Here we only need 1 use case: admin session cookie; KISS → self-signed HMAC is sufficient.
  • Reduces dependencies, easy to port, easy to read code. When expansion needed (refresh token, complex multi-role) can switch to NextAuth/JWT later.

PBKDF2 — why choose?

  • Available in Node and WebCrypto, easy to use, no native modules needed.
  • With 100_000 rounds + 16-byte salt is reasonable for small projects with fast deployment.
  • Note: if high security required, consider argon2id (needs native/bundling) or increase rounds when resources allow.

Admin Bootstrap — first-time "sealed" mailbox

  • When DB empty: allow first login to become admin.
  • If ADMIN_EMAIL and ADMIN_PASSWORD are set, only this pair can bootstrap (prevent anyone else from "grabbing" first).
  • After bootstrap: all admin create/delete operations need super admin session (username == ADMIN_EMAIL).

Feynman Explanation (as if to a 12-year-old)

  • Imagine there's a pass ticket to the management area (dashboard). The ticket has 2 parts:
    • Text line: "Who, name, expires at what time" → this is the "payload".
    • Secret stamp of the organizing committee → this is the "HMAC signature".
  • When you log in, server writes the ticket (payload), stamps it (HMAC) and gives you to keep in your pocket (HttpOnly cookie). Browser carries this ticket to the door each time entering dashboard.
  • Door protection (middleware) only does 2 simple things:
    • Compare stamp: is the stamp the organizing committee's (verify HMAC with AUTH_SECRET)?
    • Compare time: is the ticket still valid (exp > now)?
    • If both OK → come in; if not → go back to login counter.
  • Admin passwords are never sent directly to the table; before saving, server mixes password with salt and stirs 100,000 times (PBKDF2) to turn into "porridge" (hash). Even if someone sees the ledger, they only see the bowl of porridge, not the original password.
  • The "highest" person is the admin with email matching ADMIN_EMAIL. This person has the key to the "create/delete admin" room. This key is not a mysterious special right — it's just comparing username in the ticket with ADMIN_EMAIL.

Pitfall & Deployment Notes

  • Cookie secure:
    • Use secure: process.env.NODE_ENV === 'production' to allow testing on localhost (HTTP) to still work; on Vercel it will auto-enable secure.
  • System clock:
    • exp compared to "now" of edge/function server; large clock drift may cause session to expire early or late.
  • Don't put sensitive data in payload:
    • Payload should only have sub, username, name, exp. Don't stuff detailed rights or any secrets.
  • Business logic lock on BE:
    • When needed, add checks in Convex (e.g.: ban disabling last admin) to prevent direct API calls.

Quick Customization for Other Projects

  • Add other roles (e.g. manager, editor):
    • Add role flag to payload (e.g. {"role":"manager"}) and middleware authorizes by route.
    • Store role in DB admin; when logging in, embed in payload.
  • Change session time:
    • Set exp short, e.g. 2 hours; add "Keep me logged in" button to extend 8 hours if needed.
  • Change hash algorithm:
    • Replace PBKDF2 with argon2id if infrastructure allows (stronger against GPU attacks).