User registration & login
This page explains how new-user registration and login work through the auth system, end to end, from the point of view of an example application client. It walks the exact backend steps — which tables are touched, which policies run, and in what order — so you can reason about what your app’s configuration actually does. The example client throughout is claimleo (a registered app whose pool config exercises every feature); any app behaves the same way with its own config.
Companion pages: App registration
(how an app row gets created and what its fields mean),
auth-server → How it works (token
lifecycle, multi-tenant model), and the server’s
docs/USER_POOLS.md
(the pool design document). The code for everything below lives in
internal/service/auth_service.go
(the Register and Login functions) and
internal/repository/postgres/user_repository.go
(the namespace-aware lookups).
The three relations to keep separate
Everything below makes sense once you hold these apart. A user’s row in the database carries identity placement directly; app access is a separate, revocable grant; and the app row is the configuration that drives both at the moment of registration/login:
| Relation | Table | Meaning | Set when |
|---|---|---|---|
| Home pool | users.namespace | The user pool the identity lives in. Email is unique per pool — the same address can be a different user in two pools. | At registration (the app’s default pool) |
| Pool tags | user_namespaces | Additional pools the user belongs to. | At registration (the app’s other pools) |
| App access | user_apps | ”This user may enter this app.” Revocable per-app without touching identity. | First login (auto-grant) or explicit admin grant |
Pools are virtual — there is no pools table or create-a-pool step.
A pool exists because some user or app references its name. The
pairing of app names and pool names (wristleo app ↔ wristleo
pool) is convention, not structure: an app’s default pool and login
pools are whatever names you give them.
The example app’s configuration (created once by a system_admin — see App registration):
{ "code": "claimleo", "auto_grant_on_signup": true, "registration_namespace": "default", // default pool (registration) "read_namespaces": ["claimleo", "wristleo"] // other pools (login)}Registration — what the backend does
The client sends one request (the browser SDK’s
auth.register(...) produces exactly this):
POST /api/v1/auth/register{ "email": "new@example.com", "password": "Str0ngPass!", "first_name": "New", "last_name": "User", "app_code": "claimleo"}Backend steps, in order:
-
Normalize + validate the email, then resolve the app:
app_code→ the claimleo app row (falling back to the server’sAUTH_DEFAULT_APP_CODEwhen omitted). -
Apply the app’s registration policy. If the app restricts
allowed_email_domainsorallowed_auth_methods, the request is rejected here before anything is written. (claimleo restricts neither.) -
Resolve the app’s user pools. Default pool =
default; the full pool set = [default, …other pools] =["default", "claimleo", "wristleo"]. -
Collision check across the full pool set — not a global email check. The server looks for an existing user whose home pool or pool tags intersect that set (
GetByEmailInNamespaces). Three outcomes, controlled by the optionalmodefield:- exists +
mode: "register"(default) →409 user already exists; - exists +
mode: "register_or_login"→ the password is verified against the existing user and, on success, they’re logged in and reused — no duplicate identity is created. This is how an existingdefault-pool user “joins” a new app without re-registering; - exists +
mode: "register_or_return"(service-to-service only) → the existing record is returned without tokens.
- exists +
-
Create the user (all inside one transaction):
- bcrypt-hash the password; insert the user with
namespace = "default"— the app’s default pool becomes the home pool; - insert a pool tag row in
user_namespacesfor each other pool — here(user, "claimleo")and(user, "wristleo"). The user now belongs to all three pools: any future app whose login set includes one of them will find this identity; - assign the global
base_userrole; - organization association, first match wins: an
invite_code/invite_tokenjoins the inviting org with the invitation’s roles → anorganization_namecreates a new org with the user asorg_admin→ anauto_grant_on_signupapp auto-provisions the full entitlement set (app +linked_app_codesmemberships, and thedefault_organization_idmembership withdefault_role_code, defaultorg_member) → otherwise no org. Arole_code/linked_app_codesin the request body overrides the app defaults (the role is re-validated as an org-scoped role).
- bcrypt-hash the password; insert the user with
-
Send the verification email, with links pointing at the app’s own
frontend_urlso the verify page lives in the UI the user signed up in.
What exists in the database afterwards:
users: new@example.com namespace=default ← home pooluser_namespaces: (user, claimleo), (user, wristleo) ← pool tagsuser_base_roles: base_useruser_apps: (nothing yet — access is granted at first login)Note what was not stored: any direct link to the app. The app row was only the instrument; changing claimleo’s pool config later does not move existing users.
Login — what the backend does
POST /api/v1/auth/login{ "email": "new@example.com", "password": "Str0ngPass!", "app_code": "claimleo"}Backend steps, in order:
-
Per-account rate limit (Redis counter keyed by a hash of the email — independent of the per-IP limiter), so distributed password-guessing is capped per account.
-
Resolve the app FIRST — before the user lookup — because the app’s pool set defines where the user may be found. Unknown
app_code→ 404; inactive app → 403. (When no app code is given andAUTH_ALLOW_BASE_USER_LOGIN=true, a base-user token with no app claims is issued instead.) -
Identity resolution within the pool set. The email is looked up where home pool or pool tag intersects
["default", "claimleo", "wristleo"]. This is the isolation mechanism: a user whose only pool isclaimleosimply does not exist to an app whose only pool isdefault— that login fails with the sameinvalid credentialsas a wrong password (no information leak). It is also the sharing mechanism: awristleo-pool user signs straight into claimleo becausewristleois in claimleo’s login set. -
Account checks: lockout (after repeated failures), suspended, deleted.
-
Password verification (bcrypt compare; failures increment the lockout counter and are audit-logged).
-
Two-factor gate — only after the password succeeded, so 2FA’s existence is never revealed to someone who can’t authenticate. An active-2FA account without a
two_factor_codegets401 {"requires_2fa": true}; the client re-submits the same form with the code. -
Roles + permissions: with an
organization_idin the request, org membership is verified and the token gets that org’s roles; otherwise the user’s base roles. Permissions are resolved for those roles in one batched query. -
App ACCESS check — distinct from step 3’s identity check. The user must hold an active
user_appsrow for claimleo. If they don’t and the app hasauto_grant_on_signup: true, the full entitlement set is provisioned now (this is the moment access is granted): the app +linked_app_codesmemberships, plus thedefault_organization_idmembership withdefault_role_code(override-able via the request body’srole_code/linked_app_codes). This is the same idempotent path JIT-migrated users take. With auto-grant off, the login is refused until an admin grants access (POST /admin/users/{userId}/apps/{appId}). Revoking that row later blocks this app only — the user’s identity and other apps are untouched. -
Token issuance: an access/refresh pair (see auth-server → How it works for rotation + revocation). The access token’s claims carry everything downstream services need without a network hop:
{ "uid": "…", "email": "new@example.com", "app_id": "…", "app_code": "claimleo", "namespace": "claimleo", // home pool, omitted when "default" "roles": ["base_user"], "permissions": ["…"], "tv": 1, // token-version (logout-everywhere) "exp": …, "iat": …, "jti": "…"}- Audit:
login.success/login.failedevents land in the audit log.
The nuance worth remembering
Pool tagging happens only for newly created users. When an
existing default user enters claimleo for the first time (via login
or register_or_login), they gain a user_apps access row — but
they are not tagged into the claimleo pool. So:
- “claimleo’s registration cohort” = users tagged
claimleoinuser_namespaces(registered through claimleo); - “users who can use claimleo” = users with an active
user_appsrow — a different, usually larger set.
Two different questions, two different tables — by design.
Both relations are admin-editable after the fact (system_admin):
/admin/users/{id}/namespace(s) moves the home pool or adds/removes
tags, and /admin/users/{id}/apps/{appId} grants/revokes app access —
so a user can be re-pooled or re-scoped without touching their account.
See also
- App registration — creating the
app row; the
registration_namespace/read_namespaces/auto_grant_on_signupfields used above, with the pools worked example. - auth-server → How it works — JWT lifecycle, refresh rotation + theft detection, the multi-tenant org model, SSO flows.
- Browser quickstart — the
client SDK calls (
register,login) that produce these requests. - SSO bridge (Filament) — the same flows federated from a session-based Laravel app.
- Server code:
auth_service.go(Register/Login),app.godomain (WriteNamespace/EffectiveReadNamespaces),017_user_namespaces.up.sql