Intermediate May 7, 2026 · 3 min read

Two HTTP endpoints the runtime mounts when the project has the right Threads. Both are public (mounted before the JWT middleware) — clients call them to obtain or upgrade a token.

POST /auth/login

Authenticate by email + password. Mounted automatically when the project declares a User Thread with email + password_hash fields and Auth is configured (i.e., --no-auth is not set on loom serve).

Request

POST /auth/login
Content-Type: application/json

{"email": "alice@example.com", "password": "..."}

Response — 200 OK

{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "expires": "2026-05-08T15:42:00Z",
  "user_id": "8a7d0b3f-...",
  "email": "alice@example.com"
}

Response — 401 Unauthorized

Identical body for any of: no such email, wrong password, user status not active. The uniformity is deliberate — differential responses leak account state to enumeration attacks.

Invalid email or password

Response — 400 Bad Request

Malformed JSON, or missing email/password.

Response — 500 Internal Server Error

DB lookup failed, or the stored password_hash is malformed (not a valid argon2id PHC string). The latter signals data corruption and is logged loudly with the user ID.

Password verification

POST /auth/login consumes argon2id PHC strings written by runtime.HashPassword. To create a user from a signup endpoint or seed script:

hash, err := runtime.HashPassword("plaintext-from-form")
// store `hash` in user.password_hash

Defaults follow OWASP 2024 guidance: m=19MiB, t=2, p=1, 32-byte key, 16-byte salt.

What the JWT contains

{
  "sub":   "",
  "email": "",
  "roles": [],
  "iat":   1747000000,
  "exp":   1747086400
}

Note: roles is empty and tnt is absent by design. Login authenticates a user; it does NOT assign a tenant or roles. Tenant-scoped routes will 500 on this token because auth.MustTenantFromContext(ctx) finds nothing — that's the cue to call /auth/switch-tenant next.

Apps that want roles in the token from login (e.g., a global "admin" role on the User Thread itself) can replace this handler by mounting their own at /auth/login before runtime.NewServer runs.

POST /auth/switch-tenant

Exchange a logged-in user's tenantless JWT for one scoped to a tenant they're a member of. Mounted automatically when the project declares both a User Thread (the same one /auth/login requires) and a TenantMember Thread with user_id + tenant_id fields.

Request

POST /auth/switch-tenant
Authorization: Bearer 
Content-Type: application/json

{"tenant_id": "5a90c753-fc97-435c-9079-258f55e1c20c"}

Response — 200 OK

{
  "token":     "eyJhbGciOiJIUzI1NiIs... (new JWT, with tnt claim)",
  "expires":   "2026-05-08T16:30:00Z",
  "user_id":   "8a7d0b3f-...",
  "tenant_id": "5a90c753-fc97-435c-9079-258f55e1c20c",
  "role":      "owner"
}

The new JWT has tnt = tenant_id and roles = []. Use it for all subsequent tenant-scoped CRUD calls. The old token is NOT revoked server-side — clients should discard it.

Response — 401 Unauthorized

No JWT, expired JWT, or invalid signature. (Pre-checked by the Authenticator middleware before the handler runs.)

Response — 403 Forbidden

The caller is authenticated but has no TenantMember row for the requested tenant. Membership is the source of truth — global roles don't grant cross-tenant access.

Forbidden: not a member of that tenant

Response — 400 Bad Request

Malformed JSON, missing tenant_id, or tenant_id is the zero UUID.

Membership shape

TenantMember is queried as:

SELECT role FROM tenant_members WHERE user_id = ? AND tenant_id = ? LIMIT 1

The runtime expects three minimum fields: user_id (link to User), tenant_id (link to Tenant), role (text/select). Loom Core's apps/core/threads/tenant_member.yaml is the reference shape.

Apps that want richer per-tenant role hierarchies (multiple roles per membership, role inheritance, etc.) replace this handler with their own. The default places the membership row's single role value into the JWT's roles claim as a single-element slice.

When neither endpoint mounts

Condition What's mounted
--no-auth flag on loom serve Neither — JWT auth is disabled entirely; the runtime injects an anonymous Public-roled user on every /api request
No User Thread in the project Neither — login has nothing to authenticate against
User Thread missing email or password_hash field Neither — the SELECT in LoginHandler would fail
User Thread present, no TenantMember Thread /auth/login only — single-tenant apps don't need switch
Both Threads present Both endpoints mount; the server logs mounted /auth/login and mounted /auth/switch-tenant at startup

See also

  • Concepts → Multi-tenancy — what tnt does to handler SQL
  • Concepts → Secret Fields — why password_hash doesn't appear in user list responses
  • Concepts → Permissions — how roles (set by switch-tenant) get checked
Was this article helpful?