Authentication
Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /auth/register | Email/password signup |
| POST | /auth/login | Login → access + refresh tokens |
| POST | /auth/refresh | Rotate tokens |
| POST | /auth/logout | Revoke refresh token |
| GET | /auth/me | Current user (Bearer token) |
| GET | /auth/oauth/providers | Enabled social providers and callback URLs |
| POST | /auth/oauth/authorize | Start Google, Apple, or GitHub sign-in |
| GET/POST | /auth/oauth/callback/{provider} | Provider callback |
| POST | /auth/oauth/exchange | Exchange the one-use callback code for tokens |
| POST | /auth/users/import | Import stable identities for a controlled migration (project:backend key only) |
| POST | /auth/users/invite | Send a one-use project invitation (project:backend key only) |
| POST | /auth/email-verification/resend | Resend verification without revealing account existence |
| POST | /auth/email-verification/confirm | Verify email and issue a session |
| POST | /auth/invitations/accept | Accept invitation, set password, and issue a session |
| POST | /auth/password-reset/request | Send a password-reset email |
| POST | /auth/password-reset/confirm | Consume a reset token and set a password |
Register / login body
{ "email": "user@example.com", "password": "password" }
Registration validates:
- email shape (
local@domain.tld, no whitespace) - password length ≥ 6
- role must be
useroradmin(register always usesuser; CLI/admin create may setadmin)
Token response
{
"data": {
"user": { "id": "...", "email": "...", "role": "user" },
"access_token": "...",
"refresh_token": "...",
"token_type": "Bearer",
"expires_in": 900
}
}
Passwords are hashed with Argon2. They are never stored in plaintext.
The backend-only user invitation request accepts an optional application destination:
{
"email": "user@example.com",
"role": "user",
"redirect_to": "https://app.example.com/workspaces/acme/join/token"
}
redirect_to must be an absolute HTTPS URL on the configured
$auth.invitation_url origin or one of the project $origins. New users
receive the Loomup password-setup link with that destination attached. If the
identity already exists, Loomup sends the invitation template with
redirect_to as its action URL so application-level invitations are still
delivered. Omitting it preserves the original identity-invitation behavior.
When $auth.email_verification.required is enabled, registration instead
returns HTTP 202 with verification_required: true, the new user, and the
link lifetime. It does not issue a session until the one-use link is confirmed.
Password login returns email_not_verified for a correct password on an
unverified account. See Transactional auth email.
JWT secret
Configured via auth.jwt_secret_env (default LOOMUP_JWT_SECRET). If unset, the server fails closed unless auth.allow_insecure_dev_secret = true (local opt-in only), in which case auth.dev_secret is used.
Token transport (Bearer vs cookie)
By default Loomup returns Bearer access tokens in the JSON body (Authorization: Bearer … on subsequent requests). Refresh tokens are also returned in JSON.
Secure HttpOnly cookie mode
Set in loomup.toml:
[auth]
cookie_mode = true
cookie_secure = true # set false only for local HTTP without TLS
refresh_token_rotation_grace_secs = 10
Refresh tokens remain single-use outside this short rotation-only grace window. Concurrent refreshes during the window receive the same encrypted-at-rest successor; logout, password changes, user disablement, and administrative revocation remain immediate.
Refresh rejects invalid, expired, revoked, or disabled-user credentials with HTTP 401.
Internal storage and token-generation failures return HTTP 503 auth_unavailable
without clearing cookies. Clients must retain credentials on network/5xx failures
and retry with bounded backoff; only confirmed authentication rejection requires
sign-in. A lost rotation response can be recovered by promptly retrying the same
refresh token inside the rotation grace window.
When cookie_mode is true, login/register/refresh also set:
| Cookie | Purpose | Attributes |
|---|---|---|
loomup_access | Access JWT | HttpOnly, SameSite=Lax, Path=/, Max-Age = access TTL, Secure when cookie_secure |
loomup_refresh | Refresh token | same, with refresh TTL |
Credentials are accepted from the Authorization header or the loomup_access cookie. Refresh/logout may use the body token or the loomup_refresh cookie. CORS enables credentials when cookie mode is on (configure explicit cors.allowed_origins; allow_any_origin is incompatible with credentialed cookies).
The Admin UI still stores tokens in localStorage for convenience; browser apps that enable cookie mode should prefer cookies over localStorage for the refresh token.
Next.js sessions vs cookie_mode
For Next.js apps, prefer @loomup/next with Next-owned cookies (loomup-access / loomup-refresh, same as @loomup/astro) and Bearer calls to Loomup. That works when the Next app and Loomup are on different origins and does not require auth.cookie_mode.
Server cookie_mode (loomup_access / loomup_refresh with underscores, set by Loomup) is aimed at same-site or reverse-proxied browser apps that talk to Loomup directly with credentials: "include". Do not enable both session styles on the same browser origin without understanding which cookies apply where. See Next.js SDK.
Disable user and sessions
Disabling a user (PATCH /admin/api/users/{id} with { "disabled": true }) also revokes all refresh tokens for that user. Re-enabling does not restore previously revoked sessions — the user must log in again.
Google, Apple, and GitHub login
Declare the providers and every exact application callback in
loomup.schema.yaml:
$auth:
registration: public
providers: [google, apple, github]
redirect_urls:
- https://app.example.com/auth/callback
- com.example.app:/auth/callback
Redirects are exact-match only: no wildcards, query strings, or fragments.
Production web callbacks must use HTTPS; loopback HTTP and custom mobile schemes
are accepted. Managed projects derive the provider callback from their gateway
URL, for example
https://tryloomup.com/p/<project>/auth/oauth/callback/google. Add the URL
returned by GET /auth/oauth/providers to the corresponding provider console.
Apple uses form_post; Google and GitHub use query callbacks.
For a self-hosted project, also set its canonical externally reachable API
origin in loomup.toml (managed projects receive this automatically):
[auth]
public_base_url = "https://api.example.com"
Provider credentials are encrypted per project with AES-256-GCM under
.loomup/secrets/auth-providers/. Set LOOMUP_PLATFORM_SECRETS_KEY to a
base64-encoded 32-byte key before installing or reading them. The key is not
derived from the JWT secret and must be backed up separately. Credential files
are excluded from project exports and never returned by status APIs.
# Google/GitHub JSON: {"client_id":"...","client_secret":"..."}
# Apple JSON: {"client_id":"...","team_id":"...","key_id":"...","private_key_p8":"-----BEGIN PRIVATE KEY-----..."}
loomup auth-provider put google --file ./google-oauth.json --project <id>
loomup auth-provider status google --project <id>
loomup auth-provider delete google --project <id>
The platform console exposes the same metadata-only management under a
project's Social login page. It also manages the enabled provider list,
exact application redirect URLs, callback URL copy actions, and Apple .p8
upload. “Configured” means a credential is stored; “enabled” means the provider
is present in $auth.providers. Both are required. Replacing a credential
requires the complete new value; existing secrets cannot be retrieved.
The authorization endpoint creates state, nonce, provider PKCE, and a separate
client-held handoff verifier. A successful provider callback redirects to the
allowed application URL with a one-use code; the client exchanges that code
and verifier for the normal Loomup access/refresh session. Codes expire after
10 minutes and cannot be replayed.
Only provider-verified email addresses participate in linking. A first social
login creates a user account when registration is public. A later provider
with the same verified, case-insensitive email links to that user. An existing
provider cannot silently change subjects, and unverified emails, disabled
users, or unknown users while registration is disabled fail closed.
Roles
user— default on registeradmin— bypasses row-level rules; required for/admin/api/*
Create an admin:
loomup admin create-user --email admin@example.com --password 'secret12' --role admin
Row-level rules
In loomup.toml:
[tables.notes.rules]
read = "user_id = auth.uid()"
create = "auth.uid() != null"
update = "user_id = auth.uid()"
delete = "user_id = auth.uid()"
subscribe = "user_id = auth.uid()"
Supported expressions include true/false, auth.uid() != null, field = auth.uid(), auth.role() == 'admin', and AND/OR combinations.
Password reset
POST /auth/password-reset/request
{ "email": "user@example.com" }
POST /auth/password-reset/confirm
{ "token": "<selector.secret>", "password": "new-password" }
When transactional email is enabled, the request queues a branded SES message and never returns the token. A self-hosted instance with email disabled may return the token for local out-of-band delivery. Confirming a token sets the password and revokes all refresh sessions.
Controlled identity import
POST /auth/users/import preserves stable user IDs and email addresses when an
operator migrates existing accounts. It accepts 1–1000 identities per request:
{ "users": [{ "id": "existing-user-id", "email": "user@example.com" }] }
The route requires a project API key with the durable project:backend scope;
normal user tokens and narrower service keys are rejected. Imported identities
must complete the password-reset flow before password login. Conflicting IDs or
emails return 409 without silently linking different accounts.
Sessions
Admins list/revoke refresh sessions at /admin/api/sessions and /admin/api/sessions/{id}. GC expired/revoked tokens: POST /admin/api/gc/refresh-tokens.
JWT secret strength
auth.jwt_secret_min_length (default 32) is enforced when resolving the secret at startup. Short secrets fail closed.
Logout
POST /auth/logout reports failure when refresh-token revocation fails (it does not always succeed).
Data model notes
_users.disabled is paired with disabled_at; _refresh_tokens.revoked with revoked_at (timestamp semantics aligned with the PRD). Access rules remain in TOML (see access-rules.md).
Email ownership without an account
POST /email-verifications and POST /email-verifications/confirm require a
project:backend key. They are separate from account email verification: no
user, password, membership, or session is created. Applications can verify
waitlist applicants before approving an account invitation.
Creation accepts email, purpose, reference, and redirect_to, with a
required Idempotency-Key header. The callback must be an allowed project HTTPS
origin, without credentials or a fragment. The result contains id and
expires_at (Unix seconds). The raw token appears only in the emailed callback
fragment (#token=...). Applications should remove it from browser history,
exclude analytics on the confirmation page, and require an explicit POST action.
Customize the message with $email.templates.email_challenge using the existing
email template fields and placeholders.
Confirm with { "token": "selector.secret" }. The receipt contains id,
email, purpose, reference, and verified_at (Unix seconds). Validate the
purpose and application reference before updating application data. Confirmation
is repeatable until expiry, allowing recovery from an interrupted application
write. Tokens expire after 24 hours; resending invalidates earlier unverified
links for the same email/purpose/reference.
Fresh creation requests allow one email per minute and five per rolling hour per address. Identical idempotency retries bypass sending and its cooldown; reusing the key with different input returns 409. Tokens, receipts, and email outbox rows commit together. Backend-only receipts are retained for retry safety.
POST /auth/users/invite also accepts an optional Idempotency-Key. Opting in
makes invitation-token creation/rotation, email enqueue, and the receipt atomic,
with the same per-address limits (separate invitation bucket). Requests without
the header preserve the existing behavior. Existing identities receive the
provided application destination, and never have their password changed.