Baselyra Docs

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 accountApplication user
WhoYou, and whoever else operates the instanceThe end users of the app you build
Tablecontrol.platform_usersauth.users
Databasebaselyra_controlbaselyra — the project database
Signs in atPOST /admin/v1/loginPOST /auth/v1/token
Token carriesrole: service_role, typ: "platform", pr: owner|admin|viewerrole: authenticated, sub, app_metadata, user_metadata
Can open the StudioYesNever
Visible to /rest/v1No — it is in another databaseOnly through your own policies
PasswordIndependentIndependent

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 findsOutcome
No session for that token401Invalid or expired refresh token
A session with revoked_at set401 — the whole family is revoked
A session past expires_at401Invalid or expired refresh token
A live sessionA new access token and a new refresh token

Throttling

Two independent layers, protecting two different things.

LayerLimitProtects
Per IP, on the expensive routes10/min /signup; 30/min /token and /verify; 20/min /authorize; 5/min the email and OTP flowsThe process
Per account, in auth.attemptsAUTH_MAX_ATTEMPTS (8) failures per email+IP inside AUTH_ATTEMPT_WINDOW (900s), plus wider counters on the email alone and the IP aloneOne 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

RouteBodyNotes
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|globalAuth required. 204.
GET /auth/v1/userAuth 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/providersThe third-party providers configured on this instance
GET /auth/v1/authorize?provider=Starts a third-party sign-in
GET /auth/v1/callbackWhere 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 }}.

TemplateSent whenToken lifetime
confirmationSign-up, and resend24 hours
inviteAn invitation24 hours
magiclinkPOST /auth/v1/magiclink1 hour
recoveryPOST /auth/v1/recover1 hour
email_changePUT /auth/v1/user with a new email24 hours
otpPOST /auth/v1/otp with an emailAUTH_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 byUse for
user_metadataThe user, via PUT /auth/v1/userDisplay name, avatar URL, preferences
app_metadataThe service key onlyRoles, 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.

RouteBody
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/:id204
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.

RuleValue
Minimum lengthAUTH_MIN_PASSWORD_LENGTH, 8 by default
Maximum length256 bytes — scrypt is linear in input length, so an unbounded password is a CPU amplifier
Composition rulesNone. Length is the property that matters.
Unusable passwordA 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

VariableDefaultEffect
AUTH_CONFIRM_EMAILtrueRequire confirmation before the first sign-in
AUTH_ALLOW_SIGNUPStrueAllow POST /auth/v1/signup
AUTH_MIN_PASSWORD_LENGTH8
AUTH_MAX_ATTEMPTS8Failed sign-ins per account+IP before lockout
AUTH_ATTEMPT_WINDOW900That window, in seconds
AUTH_OTP_TTL600One-time code lifetime
JWT_ACCESS_TTL3600Access token lifetime
JWT_REFRESH_TTL2592000Refresh token lifetime
BASELYRA_SITE_URLWhere GET /auth/v1/verify redirects

Failure modes

What you seeWhyFix
email_not_confirmed right after signupWorking as configuredOpen the link from the log, or set AUTH_CONFIRM_EMAIL=false in development
Signup returns a user you cannot sign in asThe address already exists and the response is a decoySign in instead, or use password recovery
Everything is 401 after a redeployJWT_SECRET changed — a new .env, or setup.sh run again on a fresh cloneRestore the old secret, or accept that everyone signs in again and re-issue the anon key
Users are signed out at randomA client retries a refresh without storing the rotated token, which the server reads as reuseStore every rotated refresh token; the SDK does this for you
401 exactly one hour into a sessionThe access token expired and nothing refreshed itUse the SDK, or call /token?grant_type=refresh_token yourself
A policy sees no app_metadata changeThe claim is inside the signature and the old token is still validbl.auth.refreshSession(), or wait out the TTL
No email ever arrivesSMTP_HOST is empty, so it went to the logConfigure SMTP — Troubleshooting
A second owner account appears after a restartBASELYRA_ADMIN_EMAIL was left in .env after you changed your addressClear both admin variables

Edit this page Report a problem

Esc
navigate open Esc close