Third-party sign-in
Baselyra signs users in with seven providers. A provider is offered only when both halves of its credential are configured, the whole round trip is server-side, and the session comes back to your site in a URL fragment. There is no Apple sign-in.
The providers
| Provider | id | PKCE | Scope requested |
|---|---|---|---|
google | Yes (S256) | openid email profile | |
| GitHub | github | No | read:user user:email |
linkedin | No | openid profile email | |
facebook | Yes (S256) | public_profile,email | |
instagram | No | instagram_business_basic | |
| TikTok | tiktok | Yes (S256) | user.info.basic |
| Envato | envato | No | none — the app's permissions are set at Envato |
Configuring a provider
Register the application with the provider
The redirect URI to give them is always this, and it is built from
BASELYRA_PUBLIC_URL:https://api.example.com/auth/v1/callbackOne callback URL for every provider. If
BASELYRA_PUBLIC_URLis wrong, the provider will refuse the redirect and you will see the provider's own error page, not one of ours.Put both halves in the environment
BASELYRA_OAUTH_GOOGLE_CLIENT_ID=1234-abc.apps.googleusercontent.com BASELYRA_OAUTH_GOOGLE_CLIENT_SECRET=GOCSPX-…The variable name is
BASELYRA_OAUTH_+ the provider id uppercased +_CLIENT_ID/_CLIENT_SECRET. A provider is offered only when both are non-empty: half-configured is indistinguishable from not configured, and a sign-in button that cannot work is worse than no button.Allow the redirect targets
BASELYRA_SITE_URL=https://app.example.com BASELYRA_OAUTH_REDIRECT_ALLOWLIST=https://staging.example.com,http://localhost:5173BASELYRA_SITE_URLis always allowed. Each extra entry allows an origin; adding a path narrows the allowance to that path and below. Aredirect_tothat is not on the list is refused at/authorizeand bounced nowhere — the whole point of the list is that an unlisted target never receives a redirect from this server.Restart and check
docker compose up -d curl -s "$URL/auth/v1/providers"{"providers":[ {"id":"google","name":"Google","url":"https://api.example.com/auth/v1/authorize?provider=google"}, {"id":"github","name":"GitHub","url":"https://api.example.com/auth/v1/authorize?provider=github"}]}Render your buttons from this response and they all work by construction.
The round trip
Start it by sending the browser to /authorize. It is a full-page navigation,
not fetch — the provider has to be able to render its own consent screen.
const params = new URLSearchParams({
provider: 'google',
redirect_to: window.location.origin + '/auth/callback',
});
window.location.href = BASELYRA_URL + '/auth/v1/authorize?' + params;
The session arrives on your page as a URL fragment, exactly as an email link's does:
https://app.example.com/auth/callback#access_token=eyJ…&refresh_token=o3Hn…&expires_in=3600&expires_at=1771998842&token_type=bearer&provider=google
const p = new URLSearchParams(location.hash.slice(1));
if (p.get('error')) {
showError(p.get('error_description') ?? p.get('error'));
} else if (p.get('access_token')) {
bl.auth.setSession({
access_token: p.get('access_token'),
refresh_token: p.get('refresh_token'),
expires_in: Number(p.get('expires_in')),
expires_at: Number(p.get('expires_at')),
token_type: 'bearer',
user: null,
});
history.replaceState(null, '', location.pathname);
await bl.auth.getUser();
}
What the server does with the state
auth.oauth_statesholds one row per sign-in in flight: the state, the provider, the PKCE verifier where the provider supports it, and the already-validatedredirect_to. It lives ten minutes.- The table has RLS on and no policy, and
anonandauthenticatedare revoked from it outright. A row here is a live credential; being able to read one from/rest/v1would let any anonymous caller complete somebody else's sign-in. - The callback claims the row with
DELETE … RETURNING, so a replayed state finds nothing however fast it comes back — single use is a property of the statement, not of a later check that a second request could race past. The value is then compared again in constant time. - The
redirect_tois re-validated against the allow-list after the round trip: if the list shrank while the person was at the provider, the old target is no longer honoured. - The insert sweeps expired rows in the same statement, so the table stays bounded without a background job.
Identity linking
Every third-party account becomes a row in auth.identities, unique on
(provider, provider_id). What happens on a sign-in depends on what already
exists:
| Situation | Outcome |
|---|---|
| An identity row already matches this provider account | Sign in as that user. Checked first, so a returning user is recognised even after they change their address at the provider. |
| No identity, and no account with that email | Create a new user. Refused with 403 when AUTH_ALLOW_SIGNUPS=false. |
| No identity, an account with that email, and the provider says the address is verified | Link the provider to the existing account. |
| No identity, an account with that email, and the provider does not verify it | Blocked, with 409 conflict. |
{"error":{"code":"conflict",
"message":"That email address already belongs to an account and this provider does not verify it. Sign in with your password first, then connect this provider.",
"details":null}}
A successful sign-in also refreshes what the provider knows: the identity row's
identity_data is updated, last_sign_in_at is stamped, and
app_metadata.providers accumulates the provider id, so a user who has both a
password and Google reads as {"provider":"email","providers":["email","google"]}.
A ban is checked after the account is resolved, so it holds however the person
reaches the door.
Policies and OAuth users
Nothing about a third-party user is special in a policy. They are a row in
auth.users with auth.uid(), an email, and metadata:
-- Which providers this user has, from inside a policy
select auth.jwt() -> 'app_metadata' -> 'providers';
-- Everything they have linked
select provider, provider_id, identity_data ->> 'name'
from auth.identities
where user_id = auth.uid();
auth.identities carries a select policy for the row's own owner, so a signed-in
user can list their own linked accounts and nobody else's.
Failure modes
| What you see | Why | Fix |
|---|---|---|
400 bad_request — redirect_to is not an allowed redirect target | The target is not BASELYRA_SITE_URL and not on the allow-list | Add its origin to BASELYRA_OAUTH_REDIRECT_ALLOWLIST |
| The provider shows redirect_uri_mismatch | BASELYRA_PUBLIC_URL does not match what you registered | Register <BASELYRA_PUBLIC_URL>/auth/v1/callback exactly, scheme and all |
#error=bad_request — That sign-in provider is not enabled on this instance | One half of the credential pair is missing or misspelled | Set both _CLIENT_ID and _CLIENT_SECRET and restart |
#error=bad_request — This sign-in has expired or was already completed | More than ten minutes at the provider, the back button after a completed sign-in, or a replayed state | Start again |
#error=oauth_denied | The person cancelled at the provider | Nothing — show your sign-in page again |
#error=conflict on a first sign-in | The email belongs to an existing account and this provider does not verify addresses | Sign in with the password, then link |
A provider is missing from /auth/v1/providers | Its credentials are not both set in the running process | docker compose up -d after editing .env — nothing is read from disk at runtime |
| Sign-in works, then the user has no email | The provider did not release one — Instagram and TikTok often do not | Prompt for an address after the first sign-in; email is nullable |