Skip to content

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:

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.edit grant and an enrolled second factor. The seeded admin role holds auth.sso.edit, so the bootstrap admin can do this right after the first sign-in;
  • a public https address 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:

https://secrets.example.com/api/v1/auth/sso/google/callback

In the Google Cloud project:

  1. Set the OAuth consent screen's user type to Internal, so only accounts in your Workspace can use the client.
  2. Create an OAuth client ID of application type Web application.
  3. Add the redirect URL above under Authorized redirect URIs, exactly as the console shows it.
  4. 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 hd claim (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 with 409 role_is_sso_default, and deleting it with 409 until 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:

  1. Sign in with the password.
  2. Open the profile page and click Connect Google. It asks for a fresh second factor.
  3. Choose the Google account. It must pass the same checks as a sign-in.
  4. 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.