OAuth and OIDC sign-in
Sign people in with Google, GitHub, Microsoft Entra ID, any OpenID Connect provider, or a plain OAuth2 provider, with PKCE and explicit account linking.
OAuth 2.0 is the web standard for letting one application act with an account at another service, without ever seeing that account's password. (OIDC) builds on it to answer "who is this person?": the provider returns a signed ID token that names the account. Together they power every "Sign in with Google" button and most enterprise single sign-on.
The problem it solves. People do not want yet another password, and companies want their employees to sign in with the company account, under the company's password and MFA rules, so IT can remove access in one place. Federated sign-in gives both: the person proves who they are to Google, GitHub, Microsoft, or their company's identity provider (IdP), and your application trusts that answer.
createOAuthLogin makes your application the relying party (the side that trusts the provider). You configure
connections: one per provider and , each with the client ID and secret you
registered at that provider. Because every connection belongs to one tenant, each organization can have its own
provider, client registration, and callback URL.
Who configures it. Your team, in the deployment configuration: connections are fixed in code, not created at runtime. For a consumer button (Google, GitHub) you register one OAuth app at the provider yourself. For an enterprise customer, their IT administrator usually registers your app in their IdP (for example an Entra ID app registration) and gives you the client ID and secret, which you add as a connection. When customers should manage their own SSO without a deployment, use tenant-managed SAML connections.
The package handles the protocol and its security details. Better IAM decides who the person is: it maps the external identity to an account (an ), enforces the tenant's MFA requirements, and issues the .
Set up a connection
Create the login service with the host callbacks from iam.protocolHost and one connection per provider and
tenant, then mount it so the IAM handler serves its routes:
import { createOAuthLogin } from 'better-iam/oauth';
const login = createOAuthLogin({
...iam.protocolHost,
trustedOrigins: ['https://product.example'],
connections: [
{
id: 'org-google',
tenantId: organization.id,
kind: 'google',
clientId: secrets.googleId,
clientSecret: secrets.googleSecret,
redirectUri: 'https://product.example/oauth/google/callback',
},
],
});
iam.useProtocol(login);Register redirectUri as the callback URL at the provider: it is where the provider sends the browser back after
sign-in. Then link to /oauth/login/org-google from your sign-in page.
| Route | What it does |
|---|---|
GET /oauth/login/{connectionId} | Starts sign-in: redirects (302) to the provider. Accepts login_hint, domain_hint, and prompt query parameters. |
POST /oauth/login/{connectionId} | Starts linking the provider to the signed-in account and answers { url }. |
GET on the connection's redirectUri | The callback: finishes sign-in, clears the binding cookie, and (mounted through IAM) sets the session cookie. |
/oauth/login is the default basePath. The callback path is whatever you registered as redirectUri, and each
connection needs its own.
How sign-in works
Sign-in uses the OAuth authorization code flow: the browser visits the provider, comes back with a short-lived code, and your server exchanges that code for tokens directly with the provider, so no token passes through the browser.
Each protection in this flow stops a specific attack:
- State is a random value that must come back unchanged. It is stored in the database, bound to the tenant and
connection, expires after 10 minutes, and is deleted when the callback consumes it, so a callback cannot be
forged or replayed (
OAUTH_STATEotherwise). - The binding cookie ties the flow to the browser that started it. It is HTTP-only,
SameSite=Lax,__Host-prefixed on HTTPS, and lives 10 minutes. An attacker cannot make your browser finish their sign-in. - PKCE (Proof Key for Code Exchange) sends a hash of a secret with the request and the secret itself with the
code exchange, using
S256, so an intercepted authorization code is useless on its own. - Nonce binds the ID token to this request. OIDC kinds (
oidc,google,microsoft) send and check it and require an ID token, whose signature, issuer, and audience are validated too. - The callback URL must match the registered
redirectUriexactly (origin and path).
When the login service is mounted through IAM, a successful callback sets the standard IAM session cookie. Without
the IAM handler, call the two steps yourself: login.begin(connectionId) returns the provider url and a
binding value to keep in an HTTP-only cookie, and login.callback(connectionId, callbackUrl, binding) verifies
the response and returns the session, which you apply to your response.
Providers
Pick a preset with kind. Presets know the provider's endpoints and how it reports a verified email.
{ id: 'org-google', tenantId, kind: 'google', clientId, clientSecret, redirectUri }Google uses its fixed OIDC issuer, https://accounts.google.com, with discovery. Default scopes are openid email profile. The email counts as verified when the ID token says email_verified: true. A domainHint becomes
Google's hd parameter, which limits the account picker to one Google Workspace domain.
Choose a stable subject
Never use an email address or a mutable username as the subject of an OAuth2 mapping. The subject is the key
that ties the external account to the local one; if it can change hands, so can the account.
Microsoft Entra ID
kind: 'microsoft' signs in with Microsoft Entra ID. Entra has one sign-in endpoint for many directories (one per
customer company), so the connection has to say which directories it accepts.
microsoftTenantselects the directory:organizations(the default, any work or school tenant),common(work, school, and personal accounts),consumers(personal accounts only), or one tenant ID or domain.issuermay point at a sovereign-cloud authority instead ofhttps://login.microsoftonline.com. Every discovered endpoint must live on that authority.- Multi-tenant settings (
organizations,common,consumers) requireallowedMicrosoftTenants: the Entra tenant IDs (tid) that may sign in. A directory you have not approved cannot create or reach accounts in this tenant; it fails withOAUTH_TENANT. - The ID token must come from the concrete issuer of its own
tid, and the external identity is keyed by that issuer. domainHintbecomes Microsoft'sdomain_hint, which sends people straight to their company's sign-in page.
For a single customer, either pin allowedMicrosoftTenants to their directory or set microsoftTenant to their
tenant ID.
Options
A connection tells Better IAM which provider to use, which tenant its sign-ins belong to, and which client registration to present. Each connection takes:
Prop
Type
The service itself (createOAuthLogin) takes the connections plus a few shared settings:
Prop
Type
Configured endpoints and registered callback URLs must use HTTPS. allowInsecureLocalhost: true enables HTTP only
for loopback addresses.
Sign-in hints
Sign-in hints skip steps on the provider's page. Use them after home-realm discovery has matched an email to a connection, so the person does not type their email twice or pick an account from a list:
const { url } = await login.begin(connectionId, undefined, {
loginHint: 'ada@acme.com',
domainHint: 'acme.com',
prompt: 'select_account',
});Or on the GET start route: /oauth/login/acme-entra?login_hint=ada%40acme.com&domain_hint=acme.com.
| Hint | Effect |
|---|---|
loginHint | Pre-fills the account, usually an email address. GitHub receives it as login. |
domainHint | Becomes Microsoft's domain_hint or Google's hd, skipping the account picker for that organization. |
prompt | login forces re-authentication, select_account shows the account picker, consent asks for consent again, and none fails instead of showing any page. Not sent to GitHub. |
Hints are validated and forwarded only. They never influence which identity the callback accepts.
First sign-in and account linking
External identities are keyed by tenant, provider, issuer, and subject. What happens at the callback:
| Situation | Result |
|---|---|
| The external identity is already linked | The linked account signs in. |
| Not linked, verified email, no account with that email in the tenant | A new account is enrolled with a verified email and linked. |
| Not linked, email unverified or missing | Refused with VERIFIED_EMAIL_REQUIRED. |
| Not linked, an account with that email already exists | Refused with ACCOUNT_LINK_REQUIRED (409): the person must link explicitly. |
Same-email identities never merge automatically: otherwise anyone who could set that email address at some provider could take over the account. Federation invokes the tenant's local MFA requirements before it issues a session, so a tenant that requires MFA still asks for the product's second factor. See error codes for every code above.
Link an existing account
Linking connects a provider to an account that already exists, for example when someone who signed up with a
password wants to use Google from now on. The person signs in first, reauthenticates, and then POSTs to the same
start URL:
const response = await fetch('/oauth/login/org-google', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json', 'x-better-iam': '1' },
body: '{}',
});
const { url } = await response.json();
location.assign(url);The browser supplies Origin. Linking requires:
- an
OriginintrustedOrigins, theX-Better-IAM: 1header, and a JSON content type, so other sites cannot start it; - a current user session authenticated within the last five minutes (
RECENT_AUTH_REQUIREDotherwise, see ), so a borrowed, unlocked laptop is not enough; - the same target tenant as the connection;
- an account that is not a root administrator. Root administrators cannot use the ordinary linking flow.
Only session and identity IDs are saved with the browser-bound ceremony; raw IAM credentials are never stored. At
the callback the host rechecks the original session and identity and rejects competing mappings: an external
identity already linked to another account fails with ACCOUNT_LINK_CONFLICT. A new link is audited as
identity:link-provider.
The direct equivalent is login.begin(connectionId, credential) followed by
login.callback(connectionId, callbackUrl, binding). Protect the returned binding like the HTTP-only cookie the
built-in handler uses.
Map directory attributes
Your policies can use facts about people, such as their department. When the company's IdP already knows them,
mapAttributes(claims) copies them in at every sign-in. OIDC, Google, and Microsoft pass the verified ID token
claims; GitHub and OAuth2 pass the authenticated profile.
{
id: 'acme-okta',
tenantId,
kind: 'oidc',
issuer: 'https://acme.okta.com',
clientId,
clientSecret,
redirectUri,
mapAttributes: (claims) => ({
department: typeof claims.department === 'string' ? claims.department : undefined,
}),
}The mapped values are validated against permissions.identityAttributes inside the sign-in transaction and
replace the identity's stored attributes on every sign-in, so directory data such as a department drives
principal.department in your
policies. Return undefined to leave the
stored attributes untouched. An invalid mapping fails the sign-in closed.
Next steps
Better IAM is created by Sean Filimon
Last updated
Enterprise onboarding
Take one customer organization from "we use Okta or Entra ID" to SSO, directory provisioning, app provisioning, and end-to-end offboarding.
SAML
Accept SAML 2.0 single sign-on from Okta, Entra ID, ADFS, and Google Workspace, with tenant-managed connections, metadata import, and IdP-initiated login.