Auth
Baselyra has two completely separate identity systems, and understanding the split is the first thing to do. The rest of this page is about the second one: /auth/v1, the API your application calls.
Two populations, one instance
| Studio account | Application user | |
|---|---|---|
| Who | You, and whoever else operates the instance | The end users of the app you build |
| Table | control.platform_users | auth.users |
| Database | baselyra_control | baselyra — the project database |
| Signs in at | POST /admin/v1/login | POST /auth/v1/token |
| Token carries | role: service_role, typ: "platform", pr: owner|admin|viewer | role: authenticated, sub, app_metadata, user_metadata |
| Can open the Studio | Yes | Never |
Visible to /rest/v1 | No — it is in another database | Only through your own policies |
| Password | Independent | Independent |
auth.users is your product's data. A chat app's members. A shop's
customers. Its row count is a number you show a customer. An operator account
sitting in it is a bug, not a convenience.
This is exactly the relationship a Supabase dashboard login has to a Supabase project's users: unrelated accounts, unrelated passwords, unrelated tables. The Studio side is documented on The Studio.
Sessions and tokens
Signing in returns two tokens with very different natures.
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
"token_type":"bearer",
"expires_in":3600,
"expires_at":1771998842,
"refresh_token":"o3Hn7Vb2XkQ0…",
"user":{"id":"6c1f5c62-8e4c-4a6b-9a54-1c9d0a0d1a11",
"aud":"authenticated","role":"authenticated",
"email":"ada@example.com","phone":null,
"email_confirmed_at":"2026-08-24T09:22:00.000Z","phone_confirmed_at":null,
"confirmed_at":"2026-08-24T09:22:00.000Z",
"last_sign_in_at":"2026-08-24T09:24:02.117Z","banned_until":null,
"app_metadata":{"provider":"email","providers":["email"]},
"user_metadata":{"name":"Ada"},
"created_at":"2026-08-24T09:20:11.004Z","updated_at":"2026-08-24T09:24:02.117Z"}}
The access token is a JWT signed with JWT_SECRET. Its claims are
published to Postgres on every request as request.jwt.claims, which is what
auth.uid(), auth.role(), auth.email() and auth.jwt() read:
{"sub":"6c1f5c62-8e4c-4a6b-9a54-1c9d0a0d1a11",
"role":"authenticated",
"aud":"authenticated",
"email":"ada@example.com",
"session_id":"a91c…",
"app_metadata":{"provider":"email","providers":["email"]},
"user_metadata":{"name":"Ada"},
"iss":"baselyra","iat":1771995242,"exp":1771998842}
It is valid for JWT_ACCESS_TTL seconds (3600 by default) and
cannot be revoked before it expires. Keep the TTL short; that is what the
refresh token is for.
The refresh token is 48 opaque random bytes in auth.sessions, valid for
JWT_REFRESH_TTL (30 days). The user object never includes
encrypted_password, on any route — every field is picked by name rather than
spread, so no column added to auth.users later can leak by accident.
Rotation and theft detection
Every refresh consumes its token and issues a new one, linked to the old by
parent_id. Presenting a refresh token that was already spent is either a stolen
copy being replayed or a legitimate client replaying an old one — and the server
cannot tell which, so it assumes the worst.
{"error":{"code":"unauthorized","message":"This refresh token has already been used","details":null}}
The user signs in again; an attacker holding a stolen token gets nothing. If you see users being signed out unexpectedly, look for a client that retries a refresh after a network error without storing the new token — that is the same shape as theft from the server's point of view.
| What the server finds | Outcome |
|---|---|
| No session for that token | 401 — Invalid or expired refresh token |
A session with revoked_at set | 401 — the whole family is revoked |
A session past expires_at | 401 — Invalid or expired refresh token |
| A live session | A new access token and a new refresh token |
Throttling
Two independent layers, protecting two different things.
| Layer | Limit | Protects |
|---|---|---|
| Per IP, on the expensive routes | 10/min /signup; 30/min /token and /verify; 20/min /authorize; 5/min the email and OTP flows | The process |
Per account, in auth.attempts | AUTH_MAX_ATTEMPTS (8) failures per email+IP inside AUTH_ATTEMPT_WINDOW (900s), plus wider counters on the email alone and the IP alone | One user's password from being guessed from many addresses |
HTTP/1.1 429 Too Many Requests
retry-after: 274
{"error":{"code":"rate_limited","message":"Too many attempts. Try again in 274 seconds.","details":null}}
Nothing is locked permanently, so nobody can lock a competitor out of their own account. Timing is equalised: the password is hashed before the account lookup, so "no such user" and "wrong password" cost the same scrypt derivation.
Endpoints
| Route | Body | Notes |
|---|---|---|
POST /auth/v1/signup | {email, password, data?} | data becomes user_metadata |
POST /auth/v1/token?grant_type=password | {email, password} | |
POST /auth/v1/token?grant_type=refresh_token | {refresh_token} | Rotates |
POST /auth/v1/logout?scope=local|global | — | Auth required. 204. |
GET /auth/v1/user | — | Auth required |
PUT /auth/v1/user | {email?, password?, data?} | Auth required |
POST /auth/v1/recover | {email} | Always 200 |
POST /auth/v1/magiclink | {email} | Always 200 |
POST /auth/v1/otp | {email} or {phone} | One route, two channels — see Phone codes |
POST /auth/v1/resend | {type, email} | type: signup, confirmation, magiclink, recovery, otp |
POST /auth/v1/verify | {type, token, password?, email?, phone?} | The programmatic redemption |
GET /auth/v1/verify?type=&token= | — | What the link in an email hits |
GET /auth/v1/providers | — | The third-party providers configured on this instance |
GET /auth/v1/authorize?provider= | — | Starts a third-party sign-in |
GET /auth/v1/callback | — | Where the provider returns |
/recover, /magiclink, /otp and /resend always answer
200 with the same body whether or not the address exists, and take the same time
either way — a 350 ms floor absorbs the difference a database round trip would
otherwise reveal.
{"message":"If an account exists for that address, an email is on its way."}
Sign up
curl -s -X POST "$URL/auth/v1/signup" -H 'content-type: application/json' \
-d '{"email":"ada@example.com","password":"correct-horse-battery","data":{"name":"Ada"}}'
With AUTH_CONFIRM_EMAIL=true (the default) the response carries
session: null and the account cannot sign in until the address is confirmed.
Sign in and sign out
curl -s -X POST "$URL/auth/v1/token?grant_type=password" \
-H 'content-type: application/json' \
-d '{"email":"ada@example.com","password":"wrong"}'
{"error":{"code":"invalid_credentials","message":"Invalid login credentials","details":null}}
The same error for a wrong password and an unknown address. Two other outcomes are distinguishable, and both are checked after the password so they cannot be used as an enumeration oracle by someone who does not know it:
{"error":{"code":"user_banned","message":"This account is temporarily suspended",
"details":{"banned_until":"2026-09-01T00:00:00.000Z"}}}
{"error":{"code":"email_not_confirmed","message":"Confirm your email address before signing in","details":null}}
?scope=local (the default) revokes the current session; ?scope=global
revokes every session that user has — the "sign out everywhere" button. Neither
invalidates an already-issued access token before it expires.
Email flows
Six templates, editable in Studio → Email with a live preview and a test
send. Substitutions are {{ .ConfirmationURL }}, {{ .Token }},
{{ .Email }}, {{ .NewEmail }} and {{ .SiteURL }}.
| Template | Sent when | Token lifetime |
|---|---|---|
confirmation | Sign-up, and resend | 24 hours |
invite | An invitation | 24 hours |
magiclink | POST /auth/v1/magiclink | 1 hour |
recovery | POST /auth/v1/recover | 1 hour |
email_change | PUT /auth/v1/user with a new email | 24 hours |
otp | POST /auth/v1/otp with an email | AUTH_OTP_TTL (600s) |
Only a SHA-256 digest of each token is stored, in
auth.one_time_tokens, and each is single-use — the redeeming UPDATE
re-checks used_at IS NULL under the row lock, so of two concurrent redemptions
exactly one wins. A six-digit OTP is additionally hashed scoped to the address or
number it was sent to: twenty bits of entropy matched against every pending code
in the table at once would be far weaker than matched against one account's.
The two verify routes
GET /auth/v1/verify is what the link in an email hits. It redeems the token and
redirects to BASELYRA_SITE_URL with the result in the URL fragment:
HTTP/1.1 303 See Other
location: https://app.example.com/#access_token=eyJ…&refresh_token=o3Hn…&expires_in=3600&token_type=bearer&type=magiclink
A fragment never reaches a server log or a Referer header, which a query
string would. The destination is always BASELYRA_SITE_URL, never anything
from the request — that is what keeps this from being an open redirect. A failure
comes back the same way:
location: https://app.example.com/#error=unauthorized&error_description=This%20link%20or%20code%20is%20invalid%2C%20expired%20or%20already%20used
Read it on the landing page and hand it to the client:
const params = new URLSearchParams(location.hash.slice(1));
const access_token = params.get('access_token');
if (access_token) {
bl.auth.setSession({
access_token,
refresh_token: params.get('refresh_token'),
expires_in: Number(params.get('expires_in')),
expires_at: Math.floor(Date.now() / 1000) + Number(params.get('expires_in')),
token_type: 'bearer',
user: null,
});
history.replaceState(null, '', location.pathname); // keep the tokens out of the back button
await bl.auth.getUser();
}
POST /auth/v1/verify is the programmatic twin, for a six-digit code typed into
your own form:
await bl.auth.verifyOtp({ email, token: '123456', type: 'otp' });
await bl.auth.verifyOtp({ token: linkToken, type: 'recovery', password: 'a-new-one' });
When SMTP is not configured
With SMTP_HOST empty, nothing is sent and the message — including the link — is
printed to the app log:
[mail] SMTP not configured, would have sent to <ada@example.com>
[mail] subject: Confirm your email address
[mail] link: http://127.0.0.1:3130/auth/v1/verify?token=8Yb…&type=confirmation
Metadata
| Written by | Use for | |
|---|---|---|
user_metadata | The user, via PUT /auth/v1/user | Display name, avatar URL, preferences |
app_metadata | The service key only | Roles, plan, entitlements — anything a policy reads |
curl -s -X PUT "$URL/auth/v1/admin/users/$USER_ID" \
-H "authorization: Bearer $SERVICE_KEY" -H 'content-type: application/json' \
-d '{"app_metadata":{"role":"admin","plan":"pro"}}'
The change appears in that user's claims on their next sign-in or token refresh —
within an hour at the default TTL. To apply it immediately, have the client call
bl.auth.refreshSession().
Managing users from a server
These require the service key, never a user token. Studio → Auth is a UI over the same routes.
| Route | Body |
|---|---|
GET /auth/v1/admin/users?page=&per_page=&search= | search matches an email substring or an exact id |
POST /auth/v1/admin/users | {email, password, email_confirm?, data?} → 201 |
PUT /auth/v1/admin/users/:id | {email?, password?, data?, app_metadata?, banned_until?} |
DELETE /auth/v1/admin/users/:id | 204 |
curl -s "$URL/auth/v1/admin/users?per_page=2&search=ada" -H "authorization: Bearer $SERVICE_KEY"
{"users":[{"id":"6c1f5c62-…","email":"ada@example.com","app_metadata":{"provider":"email","providers":["email"]},
"user_metadata":{"name":"Ada"},"banned_until":null,"…":"…"}],
"total":1,"page":1,"per_page":2}
Banning is banned_until. A BEFORE UPDATE trigger restores that column for
anyone who is not service_role, so a user cannot unban themselves even if a
policy would otherwise let them update their own row — WITH CHECK sees only the
proposed row and cannot express "this column may not change", so the trigger does
it, silently, turning a self-unban into a no-op rather than an error.
Passwords
Hashed with scrypt from node:crypto — memory-hard, in the standard library, no
native build step. The stored value carries its own parameters
(scrypt$N$r$p$salt$hash, N=16384 r=8 p=1), so the cost can be raised later
without invalidating existing hashes.
A second format is recognised but never produced: bcrypt$<hash>, written by
the import engine. Verification asks Postgres
(select $1 = crypt($2, $1) via pgcrypto, which is already installed), and on the
first successful sign-in the row is quietly re-hashed to scrypt. bcrypt is not
reimplemented anywhere in this codebase. That is why users migrated from Supabase
keep their passwords and why the bcrypt rows retire themselves.
| Rule | Value |
|---|---|
| Minimum length | AUTH_MIN_PASSWORD_LENGTH, 8 by default |
| Maximum length | 256 bytes — scrypt is linear in input length, so an unbounded password is a CPU amplifier |
| Composition rules | None. Length is the property that matters. |
| Unusable password | A marker that parses as no scheme at all. Written by imports that could not carry a hash. It is rejected after burning an equivalent scrypt, so the timing does not distinguish those accounts. |
In the client
await bl.auth.signUp({ email, password, data: { name: 'Ada' } });
await bl.auth.signInWithPassword({ email, password });
await bl.auth.signInWithOtp({ email, type: 'magiclink' }); // or type: 'otp'
await bl.auth.verifyOtp({ email, token: '123456', type: 'otp' });
await bl.auth.resetPasswordForEmail(email);
await bl.auth.updateUser({ password: 'new-one' });
await bl.auth.refreshSession();
await bl.auth.signOut({ scope: 'global' });
const { data: { subscription } } = bl.auth.onAuthStateChange((event, session) => {
setUser(session?.user ?? null); // SIGNED_IN | SIGNED_OUT | TOKEN_REFRESHED | USER_UPDATED
});
The session is persisted in localStorage where it exists and in memory where it
does not, so server rendering and React Native are safe by default. The access
token is renewed a minute before it expires, and the renewed token is re-attached
to the realtime socket automatically. A refresh that fails with a 4xx clears the
session and emits SIGNED_OUT; anything else is treated as the network and retried
in ten seconds.
Configuration
| Variable | Default | Effect |
|---|---|---|
AUTH_CONFIRM_EMAIL | true | Require confirmation before the first sign-in |
AUTH_ALLOW_SIGNUPS | true | Allow POST /auth/v1/signup |
AUTH_MIN_PASSWORD_LENGTH | 8 | |
AUTH_MAX_ATTEMPTS | 8 | Failed sign-ins per account+IP before lockout |
AUTH_ATTEMPT_WINDOW | 900 | That window, in seconds |
AUTH_OTP_TTL | 600 | One-time code lifetime |
JWT_ACCESS_TTL | 3600 | Access token lifetime |
JWT_REFRESH_TTL | 2592000 | Refresh token lifetime |
BASELYRA_SITE_URL | — | Where GET /auth/v1/verify redirects |
Failure modes
| What you see | Why | Fix |
|---|---|---|
email_not_confirmed right after signup | Working as configured | Open the link from the log, or set AUTH_CONFIRM_EMAIL=false in development |
| Signup returns a user you cannot sign in as | The address already exists and the response is a decoy | Sign in instead, or use password recovery |
Everything is 401 after a redeploy | JWT_SECRET changed — a new .env, or setup.sh run again on a fresh clone | Restore the old secret, or accept that everyone signs in again and re-issue the anon key |
| Users are signed out at random | A client retries a refresh without storing the rotated token, which the server reads as reuse | Store every rotated refresh token; the SDK does this for you |
401 exactly one hour into a session | The access token expired and nothing refreshed it | Use the SDK, or call /token?grant_type=refresh_token yourself |
A policy sees no app_metadata change | The claim is inside the signature and the old token is still valid | bl.auth.refreshSession(), or wait out the TTL |
| No email ever arrives | SMTP_HOST is empty, so it went to the log | Configure SMTP — Troubleshooting |
| A second owner account appears after a restart | BASELYRA_ADMIN_EMAIL was left in .env after you changed your address | Clear both admin variables |