Sign in with Google¶
An admin can connect the company's Google Workspace from the console, with no environment variables and no redeploy. People in the allowed Workspace domains then sign in with Google. By default, the first sign-in creates an account that waits until an admin approves it and gives it a role.
Merged, not yet through QA
Google sign-in is in the api, console and worker on main, but it has
not passed a QA cycle. See Product status.
This page covers:
- setting it up: the public address, the Google OAuth client, the console settings, Test and Turn on;
- who can sign in and how accounts are matched;
- approving new accounts, and the option to skip approval;
- connecting Google to an existing account;
- the worker message for new accounts;
- how it sits next to the env-var OIDC provider and the break-glass admin.
The examples use https://secrets.example.com as the install's address and
example.com as the Workspace domain.
How it works¶
sequenceDiagram
autonumber
actor P as New user
participant UI as Console
participant API as api
participant G as Google
actor A as Admin
P->>UI: Sign in with Google
UI->>API: GET /api/v1/auth/sso/google/start
API->>G: redirect with PKCE, nonce and state
G->>API: GET /api/v1/auth/sso/google/callback?code=...
API->>API: check email_verified, hd and email domain
API->>API: create account, waiting for approval
API->>UI: session cookie, redirect
UI->>P: Waiting for approval page
A->>UI: Admin → Users, Approve with a role and scope
UI->>API: POST /api/v1/users/:id/approve
P->>UI: Check again
UI->>P: console with the granted access
Before you start¶
You need:
- an account with a global
auth.sso.editgrant and an enrolled second factor. The seededadminrole holdsauth.sso.edit, so the bootstrap admin can do this right after the first sign-in; - a public
httpsaddress for the install, the one people type in the browser; - a Google Workspace and a Google Cloud project in it where you can create OAuth clients.
Every change asks for a fresh second factor. Saving the public address
or the waiting limit, creating, editing or removing Google, replacing the
client secret, Test, Turn on and Turn off all go through the step-up dialog
(RequireFreshMFA). Whoever holds auth.sso.edit decides who can sign in,
and the step-up means a stolen session cookie alone can't change these
settings.
Set up Google sign-in¶
1. Set the public address¶
Redirect URIs are built from the install's configured public address, never
from the request's Host or X-Forwarded-* headers. A forged header can't
send the authorization code somewhere else. The address comes from one of
two places:
| Source | How | In the console |
|---|---|---|
SB_PUBLIC_URL |
Set it in the api's environment, for example in the chart's secrets.existingSecret. The worker reads the same Secret. |
Shown read-only, with "Set by SB_PUBLIC_URL on this install" |
| The console | Admin → Sign-in → Public address. The field is prefilled from the address you are using. | Editable |
When SB_PUBLIC_URL is set it always wins, and PUT /sso/settings refuses
to change the address (409 public_url_from_env).
The address must be an absolute https URL with no user info, query or
fragment. A trailing slash is dropped. Plain http is accepted only for
localhost or a loopback address, and only when SB_ENV=dev. The api
refuses to start with an invalid SB_PUBLIC_URL. The console warns, without
blocking, when a saved address isn't https or looks internal.
Google can't be turned on until an address is set, and while none is set the login page doesn't offer Google.
The Public address card also holds the waiting limit, the most accounts that can wait for approval at once. It defaults to 50 and accepts 1 to 10,000. See The waiting limit.
2. Create the OAuth client in Google Cloud Console¶
With the address saved, Admin → Sign-in → Google → Set up Google shows
the redirect URL to copy. It is always the public address plus
/api/v1/auth/sso/google/callback:
In the Google Cloud project:
- Set the OAuth consent screen's user type to Internal, so only accounts in your Workspace can use the client.
- Create an OAuth client ID of application type Web application.
- Add the redirect URL above under Authorized redirect URIs, exactly as the console shows it.
- Copy the client ID and the client secret.
Secrets Bridge uses Google's fixed issuer (https://accounts.google.com)
and its discovery document, so you don't enter an issuer. It asks for the
openid, email and profile scopes.
3. Enter the client in the console¶
In the Set up Google drawer:
| Field | What to enter |
|---|---|
| Client ID | From the OAuth client. Required, at most 255 characters, no spaces. |
| Client secret | From the OAuth client. Saved encrypted and never shown again. |
| Workspace domains | One per line, for example example.com. At least one, at most 20. They are lowercased and de-duplicated. |
| New people | Wait for approval (the default) or Let them in right away. |
| Role for new people | Only with Let them in right away. Optional. See Letting people in without approval. |
The provider is saved turned off.
The client secret is write-only. It is envelope-encrypted with the
configured KMS (a fresh data key, AES-256-GCM), the same way TOTP secrets
are stored. No response returns it, only client_secret_set: true, and it
is never logged or put in audit metadata. The console shows it as "Saved",
with Replace… to set a new one. Replacing it is its own call,
PUT /sso/providers/:id/secret, audited as sso_provider.secret_rotate.
PUT /sso/providers/:id refuses a client_secret field with 400.
4. Test, then turn it on¶
Test sends you through Google with your own account and back to Admin → Sign-in. It runs every check a real sign-in runs, creates nothing, issues no session, and works while Google is still off. The result shows as "Google sign-in works." or as the reason it didn't pass, and only the admin who ran it sees that reason.
Turn on needs a public address, a client secret and at least one
domain. If any is missing the api answers 409 provider_not_ready with a
missing list, and the console names what to set. Once on, the login page
shows Sign in with Google above the password form.
Changes take effect on the next sign-in, without a restart: the OAuth client is built from the saved settings on every round trip.
Who can sign in¶
On every Google sign-in, including returning users, the api checks:
- the email is verified by Google (
email_verified); - the ID token's
hdclaim (the Workspace domain) is present and in the allowed domains; - the email's domain is in the allowed domains as well.
hd is only present for Workspace accounts. A personal Google account with
an address in an allowed domain has no hd, so it is refused. Removing a
domain from the list refuses people in it from their next sign-in. When
exactly one domain is allowed, the api also passes it to Google as the hd
hint, so the account chooser offers only accounts in that Workspace.
Accounts are matched by Google's issuer and subject, never by email.
The binding lives in the user_identities table, unique on (issuer,
subject). The first sign-in of a Google identity:
- creates a new account when its email isn't used by any account here;
- is refused when the email already belongs to an account. That person signs in with their password and connects Google instead. This stops a Google account with, say, the bootstrap admin's address from reaching the bootstrap admin's account.
The round trip itself uses PKCE (S256), a nonce and a single-use state
that lives 5 minutes and is bound to the browser by the sb_sso_state
cookie. Return paths must be on the same origin, as for the
env-var OIDC provider.
Every refused sign-in gets the same answer. The browser goes to
/login?error=sso, and the login page says "You couldn't be signed in with
that account." whatever the reason. The page never tells an outsider which
emails have accounts. The reason goes to the audit log; see
Troubleshooting.
Approve new accounts¶
With Wait for approval on, a new account starts waiting. Until it is
approved it can only read GET /users/me and sign out. Every other route
answers 403 account_pending_approval, and the console sends the person to
a Waiting for approval page with Check again and Sign out. This
is enforced at session validation and at sign-in. If a waiting account signs in through the env-var OIDC provider,
its group mapping is skipped, so it gets no role from IdP groups either.
Admins who can approve see:
- a count on Users in the sidebar;
- a banner on Overview with a link while anyone is waiting;
- the Waiting for approval list on Admin → Users, longest wait first;
- optionally, a webhook message from the worker.
Approving and declining need a global user_role.edit. A team-scoped
grant, or team.edit, is not enough.
Approve… picks a role and, optionally, limits it to a team or a project.
The api grants the role and clears the waiting flag in one transaction, so
a failed grant leaves the account waiting. The scope is checked with the
same rules as POST /user-roles: only project_id, team_id,
environment, secret_ref_prefix and provider_type are accepted, each a
non-empty string, IDs as UUIDs. A malformed scope is refused with 400
instead of being widened. The person gets the access on their next
request, or after Check again.
Decline… disables a waiting account after a confirmation, and its
sessions end on their next use. Decline only works on waiting accounts
(409 user_not_pending otherwise), so it can't disable an active account
or the break-glass admin. A declined account keeps its waiting flag, so it
can't be approved or re-enabled from the console, and that Google account
can't sign in again.
The waiting limit¶
At most 50 accounts can wait at once by default; change the limit on
Admin → Sign-in → Public address. Past it, new Google sign-ins are
refused with the same generic page until you approve or decline some.
Declined accounts don't count. Refusals at the limit are audited as
user.pending_refused, aggregated per 10-minute window: the first refusal
in a window writes one event, and the next window writes a summary with the
count and the first and last times. A burst of sign-ins can't fill the
append-only audit log.
Letting people in without approval¶
With Let them in right away, a new account starts active. You can pick a Role for new people, granted globally when the account is created. Without one, the account starts with no role, and an admin grants one on the Users page.
The default role must be non-privileged: every permission it holds must be
in secret.list, secret.request and audit.read (audit.read only
shows a user their own events). The api refuses anything else with
400 default_role_privileged, and a default role with approval on with
400 default_role_needs_approval_off. The seeded developer role doesn't
qualify, because it holds secret.reveal.direct. Create a role with only
those permissions if you want approval off.
The rule holds after the provider is saved too:
- widening a role that a provider uses as its default
(
PUT /roles/:id/permissions) is refused with409 role_is_sso_default, and deleting it with409until you choose another default; - the role is checked again at every sign-in. If it no longer qualifies,
the new account waits for approval like any other, and the api audits
sso_provider.default_role_refused.
Connect Google to an existing account¶
Existing accounts, including the bootstrap admin, are never linked by email. The owner connects Google from their profile:
- Sign in with the password.
- Open the profile page and click Connect Google. It asks for a fresh second factor.
- Choose the Google account. It must pass the same checks as a sign-in.
- Back in the console, the profile shows the connected account.
Google must be turned on. The round trip is bound to the session that
started it: the callback parks the result in a one-time ticket that lives 2
minutes, and the console redeems it with POST /auth/sso/google/finish,
which only succeeds on that same session (otherwise 403
sso_ticket_refused). A Google identity already connected to another
account is refused (409 link_refused, reason identity_taken), and the
profile says so. A link is audited as user.identity_link.
Disconnect… also needs a fresh second factor and is audited as
user.identity_unlink. After it, the owner signs in with their password
again.
An account created by a Google sign-in can't disconnect Google. It was
given a random password nobody knows, so Google is its only way in.
DELETE /users/me/identities/google answers 409 identity_required, and
the profile explains why instead of offering Disconnect….
Notify admins from the worker¶
The worker's users-pending sweeper posts a message when accounts start
waiting. It needs SB_WORKER_WEBHOOK_URL, and isn't registered without it;
the console's count and banner still work.
| Variable | Default | Notes |
|---|---|---|
SB_WORKER_WEBHOOK_URL |
(unset) | Where to post. Required for this message. |
SB_WORKER_WEBHOOK_SLACK_FORMAT |
false |
true posts {"text": ...} for a Slack incoming webhook. |
SB_WORKER_USERS_PENDING_INTERVAL |
1m |
How often it runs, and so the most often admins hear about new accounts. At least 30s, or the worker refuses to start. |
SB_PUBLIC_URL |
(unset) | Used for the link to Admin → Users. Unset, the address saved in the console is used. |
Each run posts at most one message. It covers the accounts that started
waiting since the last message: up to 10 by email, oldest first, then "and
N more", the total still waiting when older ones are left, and a link to
<public address>/admin/users:
[info] 2 new accounts are waiting for approval
• sara@example.com
• omar@example.com
Approve or decline them in Admin → Users: https://secrets.example.com/admin/users
The message carries emails only, never display names, because a display name comes from the person's Google account and can contain anything. It goes to the webhook only. The worker logs counts, never who is waiting. Each account is announced once. A failed post is retried on the next run, together with anyone who arrived since. Declined accounts are left out.
Next to the env-var OIDC provider¶
The env-var OIDC provider
(SB_OIDC_ISSUER and the other SB_OIDC_* variables) keeps working as
before. When both are configured, the login page shows two buttons, Sign
in with Google and Sign in with single sign-on, and each provider
handles its own sign-ins. The env-var provider still matches accounts by
verified email and doesn't create waiting accounts.
When SB_OIDC_ISSUER is already Google (https://accounts.google.com),
the console refuses to add Google (409 env_provider_is_google), the login
page doesn't offer the console provider, and a provider saved earlier can't
be turned on.
Password sign-in and the break-glass admin are never turned off by these
settings. No sign-in setting disables POST /auth/login, and decline
only touches waiting accounts. If Google is misconfigured or down, sign in
with the bootstrap admin's password and fix it under Admin → Sign-in.
Audit events¶
| Action | When |
|---|---|
sso_provider.create, .update, .delete, .enable, .disable |
Google changed in the console. Old and new values of the domains, the approval setting and the default role. Never the secret, only whether one is set. |
sso_provider.secret_rotate |
The client secret replaced |
sso_settings.update |
The public address or the waiting limit changed, with old and new values |
sso_provider.test |
A Test round trip, success or denied with error_kind |
auth.sso.callback |
A Google sign-in or Connect Google round trip, success or denied with error_kind |
user.pending |
An account created waiting for approval |
user.sso_provision |
An account created active, with approval off |
user.approve, user_role.grant |
An admin approved an account, with the role and scope |
user.decline |
An admin declined an account |
user.pending_refused |
Sign-ins refused at the waiting limit, aggregated per 10 minutes |
sso_provider.default_role_refused |
A default role no longer qualified at sign-in, so the account waits |
user.identity_link, user.identity_unlink |
Google connected to or disconnected from an account |
None of them carries a token, code, ticket or secret.
API¶
The routes are listed in the HTTP API reference, and the permissions in the permissions catalog.