Skip to content

Guides

Setting up sign-in

People can sign in to the console in five ways. Two need nothing from you; three need an OAuth client at the provider.

MethodNeeds setupCounts as
Email and passwordnothingone factor
Email link or 6-digit code (passwordless)an email provider in production (RESEND_API_KEY or POSTMARK_TOKEN, plus MAIL_FROM)one factor
Continue with Googlea Google OAuth clientone factor
Continue with Microsofta Microsoft Entra app registrationone factor
Continue with Oktaan Okta OIDC app integrationone factor
Passkeynothing (uses PUBLIC_URL)two factors on its own (user verification is required)

A “Continue with” button appears on the sign-in and sign-up pages only when that provider’s variables are set. Anything that is one factor still asks for the person’s second factor (an authenticator app code, a passkey or a recovery code) when they have one, and still enforces a workspace’s “require two-factor” setting. Workspace single sign-on (an organisation’s own identity provider, set up by its owner in the console, with OpenID Connect or SAML 2.0) is separate: see docs/site/guides/workspace-sso.md for OIDC and docs/setup-saml.md for SAML with Okta, Microsoft Entra and ADFS, field by field.

#The callback address

Every provider needs the exact redirect URI. It is built from PUBLIC_URL:

Text
PUBLIC_URL/auth/oidc/google/callback
PUBLIC_URL/auth/oidc/microsoft/callback
PUBLIC_URL/auth/oidc/okta/callback

For example, with PUBLIC_URL=https://immiscible.example the Google redirect URI is https://immiscible.example/auth/oidc/google/callback. For local development with the default PUBLIC_URL, it is http://localhost:8787/auth/oidc/google/callback. The scheme, host, port and path must match exactly, with no trailing slash.

Scopes, for all three: openid email profile. We ask for nothing else, and we never call the provider’s APIs: we read the ID token, check it, and keep the provider, the subject and the email.

#Google

  1. Open the Google Cloud console, choose (or create) a project, and go to APIs and Services, then OAuth consent screen.
  2. Choose External (any Google account) or Internal (only your Google Workspace). Fill in the app name, support email, your home page, privacy notice and terms URLs, and your authorised domain (the host of PUBLIC_URL).
  3. Under Scopes, add openid, .../auth/userinfo.email and .../auth/userinfo.profile. These are non-sensitive scopes, so no Google review is needed.
  4. Publish the consent screen (Publish app). While it is in testing, only listed test users can sign in.
  5. Go to Credentials, then Create credentials, then OAuth client ID. Application type: Web application.
  6. Under Authorised redirect URIs, add PUBLIC_URL/auth/oidc/google/callback. Add the local one too if you develop against the same client. Leave Authorised JavaScript origins empty; the browser never talks to Google directly.
  7. Create, then copy the client ID and client secret.
Text
GOOGLE_CLIENT_ID=1234567890-abc.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-...

Google sends email_verified in every ID token, and we refuse the sign-in when it is not true.

#Microsoft (Entra ID and personal accounts)

  1. Open the Microsoft Entra admin centre, go to Identity, then Applications, then App registrations, and choose New registration.
  2. Name it. Under Supported account types, choose Accounts in any organizational directory and personal Microsoft accounts. This matches the default tenant, common. To admit only work and school accounts, choose the multi-tenant option without personal accounts and set MICROSOFT_TENANT=organizations; for a single organisation, choose single tenant and set MICROSOFT_TENANT to its tenant id.
  3. Under Redirect URI, choose platform Web and enter PUBLIC_URL/auth/oidc/microsoft/callback. Register.
  4. Copy the Application (client) ID from the overview page.
  5. Go to Certificates and secrets, then New client secret. Copy the secret’s Value (not its id) now; it is shown once. Put its expiry date in your calendar: sign-in with Microsoft stops when it expires.
  6. Go to Token configuration, then Add optional claim, token type ID, and tick email and xms_edov. If Entra offers to add the Microsoft Graph email permission, accept.
  7. Go to API permissions and check that Microsoft Graph has the delegated permissions openid, email and profile. No admin consent is needed for these.
Text
MICROSOFT_CLIENT_ID=00000000-0000-0000-0000-000000000000
MICROSOFT_CLIENT_SECRET=...
# optional: common (default), organizations, consumers, or a tenant id
MICROSOFT_TENANT=common

Why the optional claims matter: Microsoft does not normally send email_verified. For a work or school account we treat the address as verified only when the token carries xms_edov: true (Microsoft has verified that the email domain’s owner controls it). A personal Microsoft account’s address was verified by Microsoft when the account was made, so it is accepted. Without xms_edov, work and school sign-ins are refused with “the provider says this email address is not verified”.

With the common authority, Microsoft’s discovery document names its issuer as https://login.microsoftonline.com/{tenantid}/v2.0. We check each ID token’s issuer against the tenant id (tid) inside that same signed token.

#Okta

  1. In the Okta admin console, go to Applications, then Applications, then Create App Integration.
  2. Sign-in method: OIDC - OpenID Connect. Application type: Web Application. Next.
  3. Grant type: Authorization Code only. Leave refresh tokens and implicit off.
  4. Sign-in redirect URIs: PUBLIC_URL/auth/oidc/okta/callback. Sign-out redirect URIs: leave empty or set PUBLIC_URL/login.
  5. Assignments: choose who may use it (everyone, or chosen groups). Save.
  6. On the General tab, copy the client ID and client secret. Under Client authentication, keep Client secret (sent with HTTP Basic). PKCE is used either way; you may tick Require PKCE as additional verification.
  7. Find your issuer. With the default custom authorisation server (most orgs) it is https://<your-org>.okta.com/oauth2/default; with the org authorisation server it is https://<your-org>.okta.com. Check it by opening <issuer>/.well-known/openid-configuration: the issuer field there must equal OKTA_ISSUER exactly.
  8. If you use a custom authorisation server, make sure its access policy has a rule that allows the Authorization Code grant for this app, and that the email and profile scopes are enabled.
Text
OKTA_ISSUER=https://your-org.okta.com/oauth2/default
OKTA_CLIENT_ID=0oa...
OKTA_CLIENT_SECRET=...

Okta sends email_verified; an unverified address is refused.

#How accounts are matched

  • The first time someone continues with a provider, we look for an account with the same email address, but only if the provider says that address is verified. If it is not, the sign-in is refused and nothing is linked.
  • After that, the account is found by the provider’s own stable identifier (issuer and subject), so a later change of address at the provider does not move the person to another account.
  • If the matching account was made with a password but never confirmed its email, proving the address (through a provider, or an email link or code) claims it: its password, authenticator app, passkeys and sessions are cleared. Whoever created it never proved the address was theirs, and this closes the account pre-hijacking hole.
  • A new person gets a workspace, exactly as email sign-up gives one. When IMMISCIBLE_SIGNUPS_OPEN=false (or on a self-hosted deployment after the first owner), new people are refused and told to ask for an invitation; existing accounts still sign in.
  • Email domains whose workspace enforces single sign-on refuse personal sign-in of every kind.
  • People can see and unlink their linked providers in the console (GET /api/me/identities, DELETE /api/me/identities/:provider). Unlinking never locks anyone out, because an email link or code always reaches the account.

#Passwordless email

Choosing “Email me a sign-in link and code” sends one email with a 6-digit code and a link. They are one challenge:

  • valid for 10 minutes, and single use (whichever is used first ends both);
  • the code works only in the browser that asked for it, the link works in any browser but opens a page that asks “continue as ...” before signing in, so a mail scanner that follows links uses nothing;
  • five wrong codes end the challenge;
  • at most 5 emails per address per hour and 20 per IP address per hour.

In development (no email provider configured), messages go to the outbox table and the log. In production an email provider is required.

#Passkeys

Nothing to configure for a single host: the relying party id is the host of PUBLIC_URL, and the only accepted origin is PUBLIC_URL’s origin. Set WEBAUTHN_RP_ID only to widen it to a parent domain (for example immiscible.example when the console is on app.immiscible.example); it must be a registrable suffix of the console’s host, or browsers will refuse.

  • People add passkeys in the console after confirming their password (or an authenticator app code; or, if they have neither, within ten minutes of signing in).
  • A passkey signs in on its own when the device verifies the person (PIN or biometric).
  • Once someone has a passkey, a password alone no longer signs them in: the passkey, an authenticator app code or a recovery code is also needed. The first passkey comes with ten recovery codes, shown once.
  • We accept ES256 and RS256 keys, ask for no attestation (“none”), and refuse an assertion whose signature counter goes backwards (a sign of a cloned authenticator).

#Sessions

VariableDefaultMeaning
IMMISCIBLE_SESSION_IDLE_MINUTES1440 (one day)a session nobody uses for this long ends
IMMISCIBLE_SESSION_MAX_DAYS14a session ends this long after the sign-in that made it, however busy it is

When a person’s privileges change (they join a workspace, their role changes, they are removed from one, they turn on two-factor or add a passkey) their session token is replaced. The new cookie arrives on their next response; the old token keeps working for two minutes so requests already in flight do not fail. People can list their sessions (how each was made, when it was last used, when it will expire) and end one or all others in the console under Sessions.

#All variables

Text
PUBLIC_URL=https://immiscible.example         # the callback base and the passkey origin

GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

MICROSOFT_CLIENT_ID=
MICROSOFT_CLIENT_SECRET=
MICROSOFT_TENANT=common                       # optional

OKTA_ISSUER=
OKTA_CLIENT_ID=
OKTA_CLIENT_SECRET=

WEBAUTHN_RP_ID=                               # optional

IMMISCIBLE_SESSION_IDLE_MINUTES=1440               # optional
IMMISCIBLE_SESSION_MAX_DAYS=14                     # optional

RESEND_API_KEY= or POSTMARK_TOKEN=            # production email
MAIL_FROM=

A client id without its secret, or OKTA_CLIENT_ID without OKTA_ISSUER, is a start-up error, not a broken button.

#Routes, for reference

RoutePurpose
GET /api/auth/providerswhich sign-in options this deployment offers
GET /auth/oidc/:provider/start?next=redirect to the provider
GET /auth/oidc/:provider/callbackthe provider’s answer
POST /api/auth/email/start{ email }, answers { challenge }
GET /api/auth/email/lookup?token=who a link is for, without using it
POST /api/auth/email/verify{ token } or { challenge, code }
GET /login/email?token=the page an emailed link opens
POST /api/auth/passkey/options, POST /api/auth/passkey/verifypasskey sign-in
POST /api/auth/mfa/passkey/options, POST /api/auth/mfa/passkeya passkey as the second step after a password
GET /api/me/passkeys, POST /api/me/passkeys/options, POST /api/me/passkeys, PATCH and DELETE /api/me/passkeys/:idmanage passkeys
GET /api/me/identities, DELETE /api/me/identities/:providerlinked providers
GET /api/me/sessions, DELETE /api/me/sessions/:id, POST /api/me/sessions/end-otherssessions

#Approving above the line

Approving an agent’s request in the console at or above the workspace’s line needs a recent proof of who is approving, the same line the phone app uses: the workspace’s phone step-up line, else its maker and checker line, else £100. Data releases always need it; denying never does.

“Recent” means within five minutes of signing in or of the last proof, so a run of approvals asks once. The proof is the strongest the person has: a passkey (with user verification) or an authenticator app code; failing those, their password; failing that, a 6-digit code emailed to them. The console asks for it in place when the server answers step_up_required (POST /api/me/step-up/options, then POST /api/me/step-up).

#Phones

Signing out everywhere, adding a passkey, turning on two-factor, changing the password, and an account being claimed by proving its address all sign the person’s phones out as well: every device is revoked and its tokens deleted.