LumiBaseDocs

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 users table is fine and intended. What keeps it safe is the authorization boundary between realms, not separate tables. Self-service visitors get a least-privilege subscriber role (appAccess: false); staff are invite-only with Studio roles; session tokens carry an aud claim 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:

InterpretationVerdict
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

RealmWhoRoleappAccessOnboardingAuth
Staffeditors, admins, teammatesadministrator, membertrueInvite-only (POST /users/invite)CF Access JWT, or password /login (Studio token)
Frontend end-userpublic visitors / subscriberssubscriberfalsePublic self-service (POST /auth/register)password /login (frontend token)
Integrationserver-to-server (ISR, build)n/a (API key)per attached policyAdmin creates API keyAuthorization: Bearer lbk_…

2. Data model (single identity store)

code
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_id and every query filters by it (Strict Rule #2). RLS (withRls) is the defense-in-depth backstop.
  • subscriber is 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:

  1. Dev token (Bearer dev:<email>:<role>) — local dev only, triple-gated.
  2. Cloudflare Access JWT (cf-access-jwt-assertion header) — the primary staff/Studio flow in production.
  3. API key (Authorization: Bearer lbk_…) — integration principals.
  4. Custom JWT (Authorization: Bearer <HS256>) — issued by POST /auth/login for 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):

audMeaningCan reach Studio?Access TTLRefresh TTL
studiobootstrap admin or a role with appAccess✅ (still subject to appAccess/TFA)12h (STUDIO_SESSION_TTL)30d (STUDIO_REFRESH_TTL)
frontendsubscribers / appAccess-less roleshard-rejected by withStudioAccess30d (FRONTEND_SESSION_TTL)90d (FRONTEND_REFRESH_TTL)
email-verifyone-shot registration link tokenn/a (not a session token)24h (link)
password-resetone-shot password-reset link tokenn/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)

code
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

EndpointAuthNotes
POST /api/v1/auth/registerpublicCreates subscriber, status=invited. Per-IP rate-limited. Returns generic 202 (no enumeration).
POST /api/v1/auth/verify-emailpublicBody {token} (or ?token=). Flips invitedactive. Idempotent (already_verified).
POST /api/v1/auth/loginpublicIssues frontend/studio JWT. Gated on status='active', LoginGuard, anomaly detector.
POST /api/v1/auth/resend-verificationpublicBody {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-passwordpublicBody {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-passwordpublicBody {token,password}. Consumes a stateless password-reset token (1h TTL), sets the new password hash, and revokes all refresh tokens.
POST /api/v1/auth/refreshpublicRotates 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/logoutpublicRevokes the presented refresh token's family and clears the cookie. Idempotent.
POST /api/v1/me/change-passwordbearerBody {currentPassword,newPassword}. Verifies current password, sets new hash, revokes all refresh tokens.
GET /api/v1/me/sessionsbearerThe caller's active sessions (live refresh tokens), redacted.
DELETE /api/v1/me/sessions/:idbearerRevoke one of the caller's own sessions.
DELETE /api/v1/me/sessionsbearerRevoke ALL of the caller's sessions (logout everywhere).
GET /api/v1/auth/mebearerCurrent principal incl. isFrontendUser.

Guardrails baked in

  1. Server-resolved role — the request body cannot pick a role.
  2. Email verification — inactive until verified; stateless email-verify JWT (24h), no token table, single-use via state transition.
  3. Per-IP rate limitDEFAULT_REGISTRATION_RATE_LIMIT (5/hour), best-effort, fails open on cache outage.
  4. Anti-enumeration — identical 202 regardless of whether the email exists; password hashing only on the new-email path.

Required configuration

EnvPurpose
JWT_SECRETSigns Custom JWTs and email-verify/reset tokens. Required.
STUDIO_SESSION_TTL, FRONTEND_SESSION_TTLOptional per-realm access-token TTL (defaults 12h / 30d).
STUDIO_REFRESH_TTL, FRONTEND_REFRESH_TTLOptional per-realm refresh-token TTL / login horizon (defaults 30d / 90d).
REFRESH_COOKIE_SAMESITE, REFRESH_COOKIE_DOMAIN, REFRESH_COOKIE_SECUREOptional cross-domain refresh-cookie attributes (see §4d).
LUMIBASE_SMTP_URL, LUMIBASE_MAIL_FROMOutbound verification email. Without it, the user stays invited (no link sent).
CORS_ALLOWED_ORIGINSMust include your Next.js origin.
site siteUrlBuilds 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):

code
# 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:

KeyUsed byVars
email_verificationregister + resend-verificationemail, siteName, siteUrl, verifyUrl, token
password_resetforgot-passwordemail, 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):

code
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).

code
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):

EnvDefaultNotes
REFRESH_COOKIE_SAMESITELaxSet 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_SECUREtrueSet 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 passwordPOST /me/change-password verifies the current password, sets the new hash, and revokes all refresh tokens so other sessions can't be silently renewed. SSO/passwordless accounts get NO_PASSWORD.
  • Session management — now that refresh tokens are tracked, GET /me/sessions lists the caller's active sessions (no token material), DELETE /me/sessions/:id revokes one, and DELETE /me/sessions revokes 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:

code
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, SameSite cookie set by your Next.js route handler — not in localStorage (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 a studio token and appAccess; a frontend token 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.

SurfaceMCP-exposable?Rationale
Read — list users, read roles/policies, audit queries✅ Low riskRead-only, already admin-gated; maps cleanly to MCP tools.
Management — invite user, assign/revoke role, suspend, rotate API key⚠️ With strong gatingHigh-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 authregister, login, verify-email❌ Do not exposeThese 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 or delete* to go through ai_approvals first. 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_log already records user_registered, email_verified, api_key_*, role changes — MCP-initiated actions get the same provenance for free (set actorEmail/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

  1. 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 an ai_approvals row first.
  2. Audience integrity. MCP tools authenticate as an API-key principal (or studio), never by forging a user JWT; the frontend audience wall stays intact.
  3. No password handling. MCP tools must not accept, store, or relay end-user passwords; verification/login stay HTTP-only.
  4. Rate + scope. Bind the MCP server's API key to a narrow policy (e.g. users::read + users::update on status/roleId only) 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

ConcernFile
Register / verify-email / resend-verification / login / forgot+reset passwordapps/cms/src/routes/auth.ts
Subscriber content access endpointsapps/cms/src/routes/users.ts
Auth methods + audience parsingapps/cms/src/middleware/auth.ts
Studio access + frontend wallapps/cms/src/middleware/studio-access.ts
Subscriber role provisioningapps/cms/src/services/auth/frontend-role.ts
Subscriber content-read grantsapps/cms/src/services/auth/subscriber-access.ts
Token audiences + access/refresh TTL helpersapps/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 tokensapps/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 emailsapps/cms/src/modules/email/{verify-email,password-reset}.ts
Decision recorddocs/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 (migration 0006) is stamped on every reset/change; a reset token whose iat predates 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) (migration 0006) is the DB backstop behind the check-then-insert in /register; a lost registration race surfaces as the same generic 202.
  • 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). verifyCustomJwt requires aud ∈ {studio, frontend}; a single-purpose email-verify/password-reset JWT can never be replayed as a session token. Tokens minted before per-realm audiences existed are rejected → the holder re-authenticates.
  • /refresh re-checks realm (M4). Membership is re-verified and the audience recomputed from the role's current appAccess on 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. extractClientIp trusts CF-Connecting-IP and 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 one unknown bucket (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 setting LUMIBASE_TRUSTED_PROXIES; a full fix wires runtime getConnInfo at 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_refresh cookie 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 /refresh then 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:

sql
SELECT lower(email), count(*) FROM users GROUP BY 1 HAVING count(*) > 1;
Last modified: 23/07/2026