Skip to content
rw3iss Auth

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:

RelationTableMeaningSet when
Home poolusers.namespaceThe 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 tagsuser_namespacesAdditional pools the user belongs to.At registration (the app’s other pools)
App accessuser_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:

  1. Normalize + validate the email, then resolve the app: app_code → the claimleo app row (falling back to the server’s AUTH_DEFAULT_APP_CODE when omitted).

  2. Apply the app’s registration policy. If the app restricts allowed_email_domains or allowed_auth_methods, the request is rejected here before anything is written. (claimleo restricts neither.)

  3. Resolve the app’s user pools. Default pool = default; the full pool set = [default, …other pools] = ["default", "claimleo", "wristleo"].

  4. Collision check across the full pool setnot 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 optional mode field:

    • 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 existing default-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.
  5. 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_namespaces for 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_user role;
    • organization association, first match wins: an invite_code/invite_token joins the inviting org with the invitation’s roles → an organization_name creates a new org with the user as org_admin → an auto_grant_on_signup app auto-provisions the full entitlement set (app + linked_app_codes memberships, and the default_organization_id membership with default_role_code, default org_member) → otherwise no org. A role_code / linked_app_codes in the request body overrides the app defaults (the role is re-validated as an org-scoped role).
  6. Send the verification email, with links pointing at the app’s own frontend_url so 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 pool
user_namespaces: (user, claimleo), (user, wristleo) ← pool tags
user_base_roles: base_user
user_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:

  1. 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.

  2. 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 and AUTH_ALLOW_BASE_USER_LOGIN=true, a base-user token with no app claims is issued instead.)

  3. 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 is claimleo simply does not exist to an app whose only pool is default — that login fails with the same invalid credentials as a wrong password (no information leak). It is also the sharing mechanism: a wristleo-pool user signs straight into claimleo because wristleo is in claimleo’s login set.

  4. Account checks: lockout (after repeated failures), suspended, deleted.

  5. Password verification (bcrypt compare; failures increment the lockout counter and are audit-logged).

  6. 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_code gets 401 {"requires_2fa": true}; the client re-submits the same form with the code.

  7. Roles + permissions: with an organization_id in 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.

  8. App ACCESS check — distinct from step 3’s identity check. The user must hold an active user_apps row for claimleo. If they don’t and the app has auto_grant_on_signup: true, the full entitlement set is provisioned now (this is the moment access is granted): the app + linked_app_codes memberships, plus the default_organization_id membership with default_role_code (override-able via the request body’s role_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.

  9. 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": "…"
}
  1. Audit: login.success / login.failed events 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 claimleo in user_namespaces (registered through claimleo);
  • “users who can use claimleo” = users with an active user_apps row — 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