User Management Best Practices
Audience: operators and integrators wiring a public frontend (e.g. Next.js) to a LumiBase site where visitors register, log in, and read content — while staff manage that content in Studio.
TL;DR: One global
userstable is fine and intended. What keeps it safe is the authorization boundary between realms, not separate tables. Self-service visitors get a least-privilegesubscriberrole (appAccess: false); staff are invite-only with Studio roles; session tokens carry anaudclaim so a frontend token can never reach Studio. See ADR-011 for the decision record.
1. The core question: shared table vs. separate realms
A frequent question: "If frontend visitors register into the same table as admins, isn't that dangerous?"
There are two readings:
| Interpretation | Verdict |
|---|---|
| Give self-registered users an admin role | ❌ Never. Catastrophic. |
Store them in the same users table, different role | ✅ Fine — with strict realm separation |
The risk lives in the authorization boundary, not the table. LumiBase keeps a single identity store and separates realms by role + token audience. Benefits of one store: a single human identity (one person can be staff on one site and a subscriber on another), uniform auth, no duplicate-account drift.
Realms at a glance
| Realm | Who | Role | appAccess | Onboarding | Auth |
|---|---|---|---|---|---|
| Staff | editors, admins, teammates | administrator, member | true | Invite-only (POST /users/invite) | CF Access JWT, or password /login (Studio token) |
| Frontend end-user | public visitors / subscribers | subscriber | false | Public self-service (POST /auth/register) | password /login (frontend token) |
| Integration | server-to-server (ISR, build) | n/a (API key) | per attached policy | Admin creates API key | Authorization: Bearer lbk_… |
2. Data model (single identity store)
users (global) ← one row per human identity
├─ id (nanoid), email, passwordHash?, status (active|invited|suspended)
└─ isBootstrap
user_sites (membership N–N) ← which sites + the PRIMARY role there
└─ (userId, siteId) → roleId
roles (per site) ← administrator | member | subscriber | custom
└─ adminAccess, appAccess, systemKey
policies → permissions ← what a role can actually do (Policy DSL, ADR-008)
api_keys → api_key_roles/policies ← integration principals, scoped per site
- Domain rule: every domain table has
site_idand every query filters by it (Strict Rule #2). RLS (withRls) is the defense-in-depth backstop. subscriberis the least-privilege frontend role. It grants nothing until you attach content permissions to it (e.g.articles::read WHERE status = 'published'). It is created idempotently on first registration (ensureSubscriberRole), so existing instances need no backfill.
⚠️ Never attach Studio/admin policies to
subscriber. That role's entire purpose is to be the safe floor for self-service signups.
3. Authentication methods
withAuth (apps/cms/src/middleware/auth.ts) tries, in order:
- Dev token (
Bearer dev:<email>:<role>) — local dev only, triple-gated. - Cloudflare Access JWT (
cf-access-jwt-assertionheader) — the primary staff/Studio flow in production. - API key (
Authorization: Bearer lbk_…) — integration principals. - Custom JWT (
Authorization: Bearer <HS256>) — issued byPOST /auth/loginfor both staff (when not using CF Access) and frontend subscribers.
Token audiences (aud)
Custom JWTs carry an aud claim pinned at sign time
(services/auth/token-audience.ts):
aud | Meaning | Can reach Studio? | Access TTL | Refresh TTL |
|---|---|---|---|---|
studio | bootstrap admin or a role with appAccess | ✅ (still subject to appAccess/TFA) | 12h (STUDIO_SESSION_TTL) | 30d (STUDIO_REFRESH_TTL) |
frontend | subscribers / appAccess-less roles | ❌ hard-rejected by withStudioAccess | 30d (FRONTEND_SESSION_TTL) | 90d (FRONTEND_REFRESH_TTL) |
email-verify | one-shot registration link token | n/a (not a session token) | 24h (link) | — |
password-reset | one-shot password-reset link token | n/a (not a session token) | 1h (link) | — |
Per-realm TTL (sessionTtlFor / refreshTtlFor): the two realms no
longer share one lifetime — staff sessions are short (higher-value target,
cheap to re-auth), subscriber sessions are long (better UX, low risk). The
access JWT is the working credential; the refresh token (§4d) silently
renews it up to the refresh horizon. Overrides accept a compact duration
(12h, 30d) or a number of seconds; a malformed value falls back to the
default so a typo can't break login.
The audience wall is defense-in-depth: even if a role were
misconfigured to grant appAccess, a frontend token is rejected before
the policy bundle is consulted.
4. Self-service registration flow (frontend visitors)
Visitor (Next.js) CMS Email
│ POST /auth/register │ │
│ {email,password,name} │ rate-limit per IP (cache) │
│───────────────────────▶│ create user status=invited │
│ │ bind subscriber role (server) │
│ │ sign email-verify JWT ──────────▶ verification link
│ 202 (generic) │ audit: user_registered │
│◀───────────────────────│ │
│ │
│ click link → frontend reads ?token=… │
│ POST /auth/verify-email {token} │
│───────────────────────▶│ verify JWT, status→active │
│ {status:'verified'} │ audit: email_verified │
│◀───────────────────────│ │
│ POST /auth/login {email,password} │
│───────────────────────▶│ LoginGuard + anomaly checks │
│ {token (aud=frontend)} │ status must be 'active' │
│◀───────────────────────│ │
│ GET /items/articles (Authorization: Bearer <token>) │
Endpoints
| Endpoint | Auth | Notes |
|---|---|---|
POST /api/v1/auth/register | public | Creates subscriber, status=invited. Per-IP rate-limited. Returns generic 202 (no enumeration). |
POST /api/v1/auth/verify-email | public | Body {token} (or ?token=). Flips invited→active. Idempotent (already_verified). |
POST /api/v1/auth/login | public | Issues frontend/studio JWT. Gated on status='active', LoginGuard, anomaly detector. |
POST /api/v1/auth/resend-verification | public | Body {email}. Per-IP rate-limited. Generic 202; re-emails the activation link only for a not-yet-active, password-based account (lost-email recovery). |
POST /api/v1/auth/forgot-password | public | Body {email}. Per-IP rate-limited. Generic 202 (no enumeration); emails a reset link only for an active, password-based account. |
POST /api/v1/auth/reset-password | public | Body {token,password}. Consumes a stateless password-reset token (1h TTL), sets the new password hash, and revokes all refresh tokens. |
POST /api/v1/auth/refresh | public | Rotates the presented refresh token (cookie or body {refreshToken}) → fresh access JWT + rotated refresh token. Reuse of a revoked token revokes the whole family. |
POST /api/v1/auth/logout | public | Revokes the presented refresh token's family and clears the cookie. Idempotent. |
POST /api/v1/me/change-password | bearer | Body {currentPassword,newPassword}. Verifies current password, sets new hash, revokes all refresh tokens. |
GET /api/v1/me/sessions | bearer | The caller's active sessions (live refresh tokens), redacted. |
DELETE /api/v1/me/sessions/:id | bearer | Revoke one of the caller's own sessions. |
DELETE /api/v1/me/sessions | bearer | Revoke ALL of the caller's sessions (logout everywhere). |
GET /api/v1/auth/me | bearer | Current principal incl. isFrontendUser. |
Guardrails baked in
- Server-resolved role — the request body cannot pick a role.
- Email verification — inactive until verified; stateless
email-verifyJWT (24h), no token table, single-use via state transition. - Per-IP rate limit —
DEFAULT_REGISTRATION_RATE_LIMIT(5/hour), best-effort, fails open on cache outage. - Anti-enumeration — identical
202regardless of whether the email exists; password hashing only on the new-email path.
Required configuration
| Env | Purpose |
|---|---|
JWT_SECRET | Signs Custom JWTs and email-verify/reset tokens. Required. |
STUDIO_SESSION_TTL, FRONTEND_SESSION_TTL | Optional per-realm access-token TTL (defaults 12h / 30d). |
STUDIO_REFRESH_TTL, FRONTEND_REFRESH_TTL | Optional per-realm refresh-token TTL / login horizon (defaults 30d / 90d). |
REFRESH_COOKIE_SAMESITE, REFRESH_COOKIE_DOMAIN, REFRESH_COOKIE_SECURE | Optional cross-domain refresh-cookie attributes (see §4d). |
LUMIBASE_SMTP_URL, LUMIBASE_MAIL_FROM | Outbound verification email. Without it, the user stays invited (no link sent). |
CORS_ALLOWED_ORIGINS | Must include your Next.js origin. |
site siteUrl | Builds the …/verify-email?token= link in the email. |
4b. Granting subscribers read access to content
A freshly registered subscriber can log in but sees nothing — the
subscriber role is empty by design. Grant content read explicitly
(admin-only, requireSiteAdmin):
# Subscribers can read PUBLISHED articles (default publishedOnly=true)
POST /api/v1/users/subscriber-access
{ "collection": "articles" }
# All rows, only some fields:
POST /api/v1/users/subscriber-access
{ "collection": "pages", "publishedOnly": false, "fields": ["title","body"] }
GET /api/v1/users/subscriber-access # list current grants
DELETE /api/v1/users/subscriber-access/articles # revoke
This attaches read permissions (Policy DSL) to a shared subscriber
policy bound to the role — publishedOnly: true compiles to the row-level
filter { status: { _eq: 'published' } }. Grants take effect within ~60s
for already-authenticated subscribers (PermissionService bundle cache TTL).
Use this instead of hand-editing policies in Studio when you just want "subscribers can read published X" — it is the minimal, audited primitive.
Email templates
The verification, resend, and reset emails render a site DB template when one exists (and is enabled), else a built-in inline fallback — so the flows work out of the box. To customize, add an enabled email template with key:
| Key | Used by | Vars |
|---|---|---|
email_verification | register + resend-verification | email, siteName, siteUrl, verifyUrl, token |
password_reset | forgot-password | email, siteName, siteUrl, resetUrl, token |
verifyUrl/resetUrl are empty when the site has no siteUrl; the link is
otherwise ${siteUrl}/verify-email?token=… / ${siteUrl}/reset-password?token=….
4c. Forgot / reset password (end-users)
Self-service password recovery (distinct from the admin backup-code recovery in the Setup Wizard):
POST /auth/forgot-password { email } → generic 202; emails a 1h reset link
(link → frontend /reset-password?token=…)
POST /auth/reset-password { token, password } → sets new password hash
The reset token is a stateless password-reset JWT (same pattern as
email verification). Trade-off: no per-token revocation and the link stays
valid for its 1h TTL; rotate JWT_SECRET to invalidate all outstanding
links. A successful reset revokes all refresh tokens for the user, so
existing sessions can no longer be silently renewed (any still-valid access
JWT survives only until its short TTL expires).
4d. Silent session renewal (refresh tokens)
The access JWT (12h/30d) is a short(er) working credential; a rotating,
server-tracked refresh token renews it without re-entering credentials,
up to the realm's refresh horizon (default studio 30d / frontend 90d,
env STUDIO_REFRESH_TTL / FRONTEND_REFRESH_TTL).
POST /auth/login → { token, refreshToken, refreshTokenExpiresAt } (+ httpOnly cookie)
POST /auth/refresh → { token, refreshToken, refreshTokenExpiresAt } (rotates; cookie or body)
POST /auth/logout → revokes the family + clears the cookie
Security model (services/auth/refresh-token.ts, table lumibase_refresh_tokens):
- Hashed at rest — only
sha256(plaintext)is stored; plaintext is returned once per login/refresh. - Rotation — every refresh revokes the presented row and issues a
successor in the same
familyId(one-time use). - Reuse detection — presenting an already-revoked token (theft signal) revokes the entire family, forcing a fresh login. A benign double-submit trips this too — the accepted cost of strict rotation.
Transport — both: the token is set as an httpOnly cookie (path-scoped
to /api/v1/auth) and returned in the body. Cookie suits browser apps;
the body value suits cross-origin SPAs/SDKs where the cookie may be dropped.
Cookie attributes are env-configurable for cross-domain setups
(refreshCookieSettings):
| Env | Default | Notes |
|---|---|---|
REFRESH_COOKIE_SAMESITE | Lax | Set None when the frontend is on a different site than the API (cross-site). None forces Secure. Strict also accepted. |
REFRESH_COOKIE_DOMAIN | (host-only) | e.g. .example.com to share the cookie across subdomains (app. ↔ api.). |
REFRESH_COOKIE_SECURE | true | Set false only for local http dev. Ignored (forced true) when SameSite=None. |
For a cross-site browser frontend you must also: serve both over HTTPS, set
CORS_ALLOWED_ORIGINS to the exact frontend origin (no *), and have the
client send credentials: 'include'.
CSRF: because the cookie is ambient (auto-sent under SameSite=None),
/refresh and /logout require a custom X-LumiBase-Refresh header
when the token comes from the cookie. A cross-site simple request cannot set
a custom header (and doing so from JS triggers a CORS preflight the server
gates), so this neutralises CSRF for the cookie path. Body-token callers are
exempt. The header is allow-listed in CORS; cross-site clients must send it
(any non-empty value).
4e. Account self-service (authenticated)
For a logged-in user (bearer, under /api/v1/me):
- Change password —
POST /me/change-passwordverifies the current password, sets the new hash, and revokes all refresh tokens so other sessions can't be silently renewed. SSO/passwordless accounts getNO_PASSWORD. - Session management — now that refresh tokens are tracked,
GET /me/sessionslists the caller's active sessions (no token material),DELETE /me/sessions/:idrevokes one, andDELETE /me/sessionsrevokes all (logout everywhere). Expired/revoked rows are swept by the hourly prune (§4d).
5. Staff onboarding (do NOT use self-service)
Staff are created invite-only:
POST /api/v1/users/invite (requireSiteAdmin)
{ "email": "editor@acme.com", "roleId": "<member|administrator role id>" }
This creates/links the user with a Studio role (appAccess: true) and
sends a best-effort invite email. Enforce TFA on admin roles via a policy
with enforceTfa: true (withStudioAccess then requires a TFA-verified
session).
6. Frontend (Next.js) integration notes
- Store the token as an httpOnly,
Secure,SameSitecookie set by your Next.js route handler — not inlocalStorage(XSS exfiltration). - Send the tenant: include
X-Lumi-Site: <siteId>(or rely on subdomain resolution) on every CMS call. - Never call Studio APIs (
/collections,/roles,/users, …) from the public frontend — those require astudiotoken andappAccess; afrontendtoken is rejected by design. - Use an API key for build-time/ISR server-to-server reads, not a user token; scope it to a read-only policy.
- Token lifetime is 24h; implement a refresh/re-login UX.
7. Exposing user management over MCP (future feasibility)
Can this surface later be exposed via MCP (Model Context Protocol)? Evaluation below. [Inference] — a design assessment, not a shipped capability.
Grounding: LumiBase already ships an MCP server —
packages/mcp-server (name: 'lumibase', stdio transport). It exposes
items / collections / fields tools today and authenticates exactly the
way this evaluation recommends: a LumiBaseClient carrying
Authorization: Bearer <api key> + X-Lumi-Site: <siteId>
(packages/mcp-server/src/client.ts). So "exposing user management over
MCP" concretely means adding a tools/users.ts alongside the existing
tool modules, reusing the same API-key client — not building new infra.
What MCP would be here: an MCP server exposing user/role operations as tools an AI agent (or external MCP client) can call.
Recommended split
| Surface | MCP-exposable? | Rationale |
|---|---|---|
| Read — list users, read roles/policies, audit queries | ✅ Low risk | Read-only, already admin-gated; maps cleanly to MCP tools. |
| Management — invite user, assign/revoke role, suspend, rotate API key | ⚠️ With strong gating | High-impact. Expose only behind an API key bound to an explicit admin policy, per-site scoped, and route privilege-changing calls through the HITL ai_approvals path. |
Public auth — register, login, verify-email | ❌ Do not expose | These are end-user credential flows, not agent capabilities. An agent should never self-register accounts or handle user passwords. |
Why it fits LumiBase cleanly
- Capability + HITL already exist. Strict Rule #4 requires skills with
schema:write-class capability ordelete*to go throughai_approvalsfirst. User-mutation tools (assign admin role, delete user) are exactly the dangerous class — reuse that gate; do not invent a side channel. - Per-site scoping is native. Every table is
site_id-scoped and API keys are bound to one site, so an MCP tool inherits tenant isolation. - Audit is native.
audit_logalready recordsuser_registered,email_verified,api_key_*, role changes — MCP-initiated actions get the same provenance for free (setactorEmail/metadata to the agent). - Earned autonomy (L0–L4). MCP user-management tools map onto the Content OS autonomy ladder: start at L1 (propose → human approves), promote to veto-window/autopilot only for low-risk ops (e.g. read, resend invite), never for role elevation.
Hard constraints if/when implemented
- Never mint or elevate a principal without HITL. Any tool that can
grant
appAccess/adminAccess, create an admin, or issue an API key MUST create anai_approvalsrow first. - Audience integrity. MCP tools authenticate as an API-key principal
(or
studio), never by forging a user JWT; thefrontendaudience wall stays intact. - No password handling. MCP tools must not accept, store, or relay end-user passwords; verification/login stay HTTP-only.
- Rate + scope. Bind the MCP server's API key to a narrow policy
(e.g.
users::read+users::updateonstatus/roleIdonly) rather than admin bypass.
Bottom line: the management surface is a good future MCP candidate
because the safety primitives (capability gating, HITL, per-site scope,
audit, autonomy levels) are already in place — and the delivery vehicle
(packages/mcp-server + its API-key client) already exists, so the work is
a new tools/users.ts module plus an admin-scoped key, not new
infrastructure. The public auth surface should remain ordinary HTTP and
never become an agent tool.
8. Quick reference — files
| Concern | File |
|---|---|
| Register / verify-email / resend-verification / login / forgot+reset password | apps/cms/src/routes/auth.ts |
| Subscriber content access endpoints | apps/cms/src/routes/users.ts |
| Auth methods + audience parsing | apps/cms/src/middleware/auth.ts |
| Studio access + frontend wall | apps/cms/src/middleware/studio-access.ts |
| Subscriber role provisioning | apps/cms/src/services/auth/frontend-role.ts |
| Subscriber content-read grants | apps/cms/src/services/auth/subscriber-access.ts |
| Token audiences + access/refresh TTL helpers | apps/cms/src/services/auth/token-audience.ts |
| Rotating refresh tokens (issue/rotate/revoke) | apps/cms/src/services/auth/refresh-token.ts (table lumibase_refresh_tokens) |
| Email-verify / password-reset tokens | apps/cms/src/services/auth/{email-verification,password-reset}.ts |
| Per-IP rate limit (register/resend/forgot) | apps/cms/src/modules/auth/registration-guard.ts |
| Verification / reset emails | apps/cms/src/modules/email/{verify-email,password-reset}.ts |
| Decision record | docs/en/architecture/decisions/adr-011-user-management-realms.md |
9. Hardening notes & known limitations
Verified fixes shipped with this feature (see CHANGELOG):
- Single-use password-reset (H1).
users.password_changed_at(migration0006) is stamped on every reset/change; a reset token whoseiatpredates it is rejected (isResetTokenStale). A leaked or replayed link cannot set a second password, and issuing a newer reset invalidates older links. - Global unique email (H3). Unique index on
lower(email)(migration0006) is the DB backstop behind the check-then-insert in/register; a lost registration race surfaces as the same generic202. - Atomic refresh rotation (M1). Rotation claims the row with a
conditional
UPDATE … WHERE revoked_at IS NULL; a concurrent loser is treated as reuse and the family is revoked. - Audience-pinned session verify (M5).
verifyCustomJwtrequiresaud ∈ {studio, frontend}; a single-purposeemail-verify/password-resetJWT can never be replayed as a session token. Tokens minted before per-realm audiences existed are rejected → the holder re-authenticates. /refreshre-checks realm (M4). Membership is re-verified and the audience recomputed from the role's currentappAccesson every renewal.
Known limitations — tracked follow-ups, not yet fixed (accept or address before relying on them in a hostile deployment):
- H2 — IP-based rate limiting off Cloudflare.
extractClientIptrustsCF-Connecting-IPand has no wired remote-address resolver on the Node runtime, so on a bare Docker deployment an attacker can rotate the header (bypass the limiter) or collapse all callers into oneunknownbucket (DoS registration/reset for the whole site). This is a pre-existing, app-wide limitation shared with the login-guard. Mitigate by fronting the API with Cloudflare (or a proxy that strips/sets the header) and settingLUMIBASE_TRUSTED_PROXIES; a full fix wires runtimegetConnInfoat the auth call sites. - M2 — no absolute session lifetime. Refresh rotation grants a fresh TTL every hop, so a chain that refreshes at least once per refresh-TTL window lives indefinitely. Only a password change (which revokes all tokens) or explicit logout/session-revoke ends it. Add a family-origin cap if an absolute cap is required.
- L2 — refresh cookie is host-scoped across tenants. The
lumibase_refreshcookie uses one name per host; on a shared-host multi-tenant deployment, logging into site B overwrites site A's cookie (a subsequent site-A cookie/refreshthen 401s and clears). Body-token transport is unaffected — cross-tenant SPAs should prefer it.
Operational note — the lower(email) unique index (0006) fails to create
if the users table already holds case-insensitive duplicate emails.
De-duplicate first:
SELECT lower(email), count(*) FROM users GROUP BY 1 HAVING count(*) > 1;