Loomup Docs
Guide /docs/auth

Authentication

Endpoints

MethodPathDescription
POST/auth/registerEmail/password signup
POST/auth/loginLogin → access + refresh tokens
POST/auth/refreshRotate tokens
POST/auth/logoutRevoke refresh token
GET/auth/meCurrent user (Bearer token)
GET/auth/oauth/providersEnabled social providers and callback URLs
POST/auth/oauth/authorizeStart Google, Apple, or GitHub sign-in
GET/POST/auth/oauth/callback/{provider}Provider callback
POST/auth/oauth/exchangeExchange the one-use callback code for tokens
POST/auth/users/importImport stable identities for a controlled migration (project:backend key only)
POST/auth/users/inviteSend a one-use project invitation (project:backend key only)
POST/auth/email-verification/resendResend verification without revealing account existence
POST/auth/email-verification/confirmVerify email and issue a session
POST/auth/invitations/acceptAccept invitation, set password, and issue a session
POST/auth/password-reset/requestSend a password-reset email
POST/auth/password-reset/confirmConsume a reset token and set a password

Register / login body

json
{ "email": "user@example.com", "password": "password" }

Registration validates:

  • email shape (local@domain.tld, no whitespace)
  • password length ≥ 6
  • role must be user or admin (register always uses user; CLI/admin create may set admin)

Token response

json
{
  "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:

json
{
  "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.

By default Loomup returns Bearer access tokens in the JSON body (Authorization: Bearer … on subsequent requests). Refresh tokens are also returned in JSON.

Set in loomup.toml:

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:

CookiePurposeAttributes
loomup_accessAccess JWTHttpOnly, SameSite=Lax, Path=/, Max-Age = access TTL, Secure when cookie_secure
loomup_refreshRefresh tokensame, 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.

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:

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

toml
[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.

console
# 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 register
  • admin — bypasses row-level rules; required for /admin/api/*

Create an admin:

bash
loomup admin create-user --email admin@example.com --password 'secret12' --role admin

Row-level rules

In loomup.toml:

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

http
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:

json
{ "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.