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.
- Login page
-
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: hascreate
,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
})
Session Token (HttpOnly cookie)
- 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
(orNEXTAUTH_SECRET
if shared).
- Payload:
- Store
adminSession
cookie with properties:httpOnly: true
,sameSite: 'lax'
.secure: process.env.NODE_ENV === 'production'
(Vercel compatible).maxAge: 8 hours
.
Login + Admin Bootstrap Flow
- User submits
POST /api/admin/login
withusername
,password
. - Server checks:
- If DB has no admin:
- If
ADMIN_EMAIL
andADMIN_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
- If DB has admin: authenticate normally.
- If DB has no admin:
- Return
adminSession
cookie if successful.
Protecting /dashboard/**
- Middleware reads
adminSession
cookie, verifies HMAC andexp
. - 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
- Create
admins
table in Convex schema (withby_username
,by_active
indexes). - Add Convex module
admins.ts
with functions:create
,update
,changePassword
,toggleActive
,listBrief
,getByUsernameWithHash
,deleteById
.
- Add
lib/session.ts
to sign/verify HMAC. - Add API routes for login, create/delete/change password for admins, create/change password for members.
- Add middleware to block
/dashboard/**
. - Create
/sign-in
page + "Logout" button. - (Optional) Hide sensitive UI buttons when not super admin (use
/api/admin/me
). - Set env:
AUTH_SECRET
,NEXT_PUBLIC_CONVEX_URL
,ADMIN_EMAIL
,ADMIN_PASSWORD
(bootstrap).
Security & Operations Suggestions
- Always use HTTPS (Vercel production sets
secure=true
viaNODE_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.
- No. Session cookie has short
-
"Do we need to encrypt cookies?"
- Cookie is HttpOnly and HMAC-signed; payload contains no sensitive info (only
sub
,username
,name
,exp
).
- Cookie is HttpOnly and HMAC-signed; payload contains no sensitive info (only
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)
→ base64urlsig_b64
. - Step 4: final token:
payload_b64.sig_b64
.
- Step 1: generate JSON payload:
- Verify token (middleware/API):
- Step 1: split token by dot into
payload_b64
andsig_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.
- Step 1: split token by dot into
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
andADMIN_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.
- Compare stamp: is the stamp the organizing committee's (verify HMAC with
- 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 comparingusername
in the ticket withADMIN_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.
- Use
- 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.
- Payload should only have
- 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.
- Add
- Change session time:
- Set
exp
short, e.g. 2 hours; add "Keep me logged in" button to extend 8 hours if needed.
- Set
- Change hash algorithm:
- Replace PBKDF2 with
argon2id
if infrastructure allows (stronger against GPU attacks).
- Replace PBKDF2 with