Baselyra Docs

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

ProvideridPKCEScope requested
GooglegoogleYes (S256)openid email profile
GitHubgithubNoread:user user:email
LinkedInlinkedinNoopenid profile email
FacebookfacebookYes (S256)public_profile,email
InstagraminstagramNoinstagram_business_basic
TikToktiktokYes (S256)user.info.basic
EnvatoenvatoNonone — the app's permissions are set at Envato

Configuring a provider

  1. 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/callback

    One callback URL for every provider. If BASELYRA_PUBLIC_URL is wrong, the provider will refuse the redirect and you will see the provider's own error page, not one of ours.

  2. 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.

  3. Allow the redirect targets

    BASELYRA_SITE_URL=https://app.example.com
    BASELYRA_OAUTH_REDIRECT_ALLOWLIST=https://staging.example.com,http://localhost:5173

    BASELYRA_SITE_URL is always allowed. Each extra entry allows an origin; adding a path narrows the allowance to that path and below. A redirect_to that is not on the list is refused at /authorize and bounced nowhere — the whole point of the list is that an unlisted target never receives a redirect from this server.

  4. 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

Browser Baselyra Provider 1 GET /auth/v1/authorize?provider=google 2 insert state + PKCE verifier auth.oauth_states, 10 minutes 3 302 to the provider, with state and S256 challenge 4 the person signs in and consents 5 GET /auth/v1/callback?code=…&state=… 6 DELETE … RETURNING single use is a property of the statement 7 POST token (code + verifier), then GET userinfo 8 link or create, then start a session 9 303 to your site, tokens in the URL #fragment
Nine steps, none of which the browser can forge. The state value and the PKCE verifier never leave the server, and the state row is claimed with a DELETE so a replay finds nothing.

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_states holds one row per sign-in in flight: the state, the provider, the PKCE verifier where the provider supports it, and the already-validated redirect_to. It lives ten minutes.
  • The table has RLS on and no policy, and anon and authenticated are revoked from it outright. A row here is a live credential; being able to read one from /rest/v1 would 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_to is 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:

SituationOutcome
An identity row already matches this provider accountSign 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 emailCreate 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 verifiedLink the provider to the existing account.
No identity, an account with that email, and the provider does not verify itBlocked, 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 seeWhyFix
400 bad_requestredirect_to is not an allowed redirect targetThe target is not BASELYRA_SITE_URL and not on the allow-listAdd its origin to BASELYRA_OAUTH_REDIRECT_ALLOWLIST
The provider shows redirect_uri_mismatchBASELYRA_PUBLIC_URL does not match what you registeredRegister <BASELYRA_PUBLIC_URL>/auth/v1/callback exactly, scheme and all
#error=bad_requestThat sign-in provider is not enabled on this instanceOne half of the credential pair is missing or misspelledSet both _CLIENT_ID and _CLIENT_SECRET and restart
#error=bad_requestThis sign-in has expired or was already completedMore than ten minutes at the provider, the back button after a completed sign-in, or a replayed stateStart again
#error=oauth_deniedThe person cancelled at the providerNothing — show your sign-in page again
#error=conflict on a first sign-inThe email belongs to an existing account and this provider does not verify addressesSign in with the password, then link
A provider is missing from /auth/v1/providersIts credentials are not both set in the running processdocker compose up -d after editing .env — nothing is read from disk at runtime
Sign-in works, then the user has no emailThe provider did not release one — Instagram and TikTok often do notPrompt for an address after the first sign-in; email is nullable

Edit this page Report a problem

Esc
navigate open Esc close