OAuth/OIDC provider
Run a tenant-aware OAuth 2.0 and OpenID Connect authorization server for your own apps, CLIs, devices, and service accounts.
An authorization server is the service other applications send people to when they need them to sign in, and that hands those applications tokens afterwards. When you enable it, Better IAM becomes an identity provider like Google or Okta, but for your own product: "Sign in with Acme".
The problem it solves. As a product grows, more software needs your users' identity: a second web app, a mobile app, a CLI, a partner integration, a TV app, a background job. Letting each of them collect passwords is insecure, and building a custom token scheme for each is slow. OAuth 2.0 and are the standards all of these clients already speak. The person signs in once with Better IAM, approves the app (consent), and the app receives:
- an ID token that says who signed in (OpenID Connect),
- a short-lived access token to call your APIs,
- and optionally a refresh token to get new access tokens without asking again.
createOAuthProvider builds this server. Every client belongs to one , every consent
is bound to the IAM that gave it, and ending that session ends the tokens. The protocol engine is
oidc-provider; Better IAM supplies the storage (encrypted,
tenant-bound records in your database), the accounts, the consent rules, and client administration.
Who configures it. Your team sets up the provider, declares your APIs, and builds its sign-in and consent pages once. Tenant administrators then register the applications (clients) that may use it, through an admin screen you build on the client methods below or a script, and people manage the apps they connected from a "Connected apps" page.
What it supports
Each row is a standard OAuth or OpenID Connect feature. You do not have to use all of them: most products start with the authorization code flow and refresh tokens, and add the others when a client needs them.
| Capability | What it is for |
|---|---|
| Discovery and JWKS | Clients read the server's endpoints and its JSON Web Key Set (JWKS, the public signing keys) from standard URLs, including /.well-known/oauth-authorization-server{issuer path}, instead of being configured by hand. |
| Authorization code with PKCE | The standard browser sign-in flow. It is the only enabled response type, and PKCE (a per-request secret that makes a stolen code useless) is required for every client. |
| Refresh tokens | Let an app stay signed in without asking again. They rotate on every use, and reusing an old one revokes the whole chain. |
| Client credentials | Machine-to-machine access for a backend job, tied to an active . No person is involved. |
| Device authorization | Sign-in for devices without a browser or keyboard (TVs, CLIs): the device shows a code, the person approves it on their phone or laptop. |
| UserInfo | Returns the signed-in person's claims to a client holding an access token. |
| Introspection and revocation | Let a client check whether a token is still valid, or cancel it. A client may only do this for its own tokens. |
| RP-initiated logout | Lets an app (the relying party, RP) send the person to Better IAM to sign out, with your own logout page. |
| Back-channel logout | Tells apps server-to-server that a person's session ended. See Back-channel logout. |
| Pushed authorization requests (PAR, RFC 9126) | The client sends its sign-in request to the server first, so its parameters stay out of the browser URL. See Pushed authorization requests. |
| Resource indicators (RFC 8707) and JWT access tokens (RFC 9068) | Tokens meant for one API only, which that API verifies offline with the public keys. See Resource servers and tokens. |
| (RFC 9449) | Binds a token to a key the client holds, so a stolen token is useless on its own. See DPoP. |
| Token exchange (RFC 8693) | Lets one of your APIs call another for the same user with a new, narrower token. See Token exchange. |
| Dynamic client registration (RFC 7591) | Lets clients such as MCP hosts register themselves over HTTP. Off unless registration is configured. See Dynamic registration and MCP. |
The development interaction UI is disabled: login and consent pages belong to your application.
Set up the provider
Create the provider with the host callbacks, persistent keys, and the URLs of your own sign-in pages, then mount it:
import { createOAuthProvider } from 'better-iam/oauth';
export const issuer = createOAuthProvider({
...iam.protocolHost,
issuer: 'https://identity.example/oidc',
jwks: secrets.privateSigningJwks,
cookieKeys: secrets.cookieSigningKeys,
encryptionKey: secrets.base64Encoded32ByteEncryptionKey,
trustedOrigins: ['https://identity.example'],
scopes: ['documents:read'],
interactionUrl: (uid) => `https://identity.example/interactions/${uid}`,
renderDevicePage: ({ kind, form }) => renderDeviceScreen(kind, form),
renderLogoutPage: ({ form }) => renderLogoutScreen(form),
});
iam.useProtocol(issuer);Prop
Type
A scope is a named piece of access a client asks for, such as email or documents:read. Scope names are 1 to
128 letters, digits, and : _ . -. The provider always supports openid, email, profile, offline_access
(ask for a refresh token), and iam, plus your scopes and every resource server's scopes.
Keys
Supply persistent signing keys, cookie-signing keys, and a separate 32-byte encryption key. Keep the same keys across replicas and process restarts, and load them from your secret store.
Never regenerate keys at startup
Replacing the signing keys breaks verification of every JWT and ID token already issued, replacing the cookie keys ends pending interactions and provider sessions, and a new encryption key makes every stored protocol artifact unreadable. Signing-key rollover uses a JWKS containing the active private key and the still-valid verification keys. Changing the encryption key requires re-encrypting stored protocol artifacts; automatic key migration is not provided.
Mount it
iam.useProtocol(issuer) registers the provider. iam.nodeHandler(req, res) then serves the complete
authorization server: it passes every request under the issuer's path, and the metadata path
/.well-known/oauth-authorization-server{issuer path}, to the provider on Node's request and response objects.
You can also call issuer.nodeHandler(req, res) yourself. See
Protocol mounts.
The configured issuer stays fixed: metadata and generated endpoint URLs retain its mount path, so an issuer of
https://identity.example/oidc serves its token endpoint at https://identity.example/oidc/token and its public
keys at https://identity.example/oidc/jwks.
Authorization code flow
This is what happens when a person clicks "Sign in with Acme" in one of your client apps:
Build the interaction pages
An interaction is the part of the flow where a person has to do something: sign in, or approve a client. The provider hands it to your application, because the look of your sign-in and consent screens, and which sign-in methods they offer, are product decisions.
issuer.interactionDetails(req, res)tells your page what is being asked: which client, which tenant, which scopes. Call it onGETand render the screen.issuer.completeInteraction(req, res, { credential, consent })records the person's answer and sends the browser back to the client. Call it onPOST.
import type { IncomingMessage, ServerResponse } from 'node:http';
export async function interaction(req: IncomingMessage, res: ServerResponse) {
const credential = { headers: toHeaders(req.headers) }; // the person's IAM session cookie
if (req.method === 'GET') {
const details = await issuer.interactionDetails(req, res);
// details.client: name, logoUri, clientUri, policyUri, tosUri, firstParty
// details.scopes and details.resources: what the client asks for
res.setHeader('content-type', 'text/html; charset=utf-8');
res.end(renderConsentPage(details));
return;
}
const form = await readForm(req);
await issuer.completeInteraction(req, res, {
credential,
consent: form.get('consent') === 'yes',
});
}If the person is not signed in yet, show your normal Better IAM sign-in (password, passkey, SSO, MFA) on the same page first; the consent step then uses the session it creates.
completeInteraction enforces these rules:
- The request is a
POSTwhoseOriginis intrustedOrigins(CSRFotherwise). - The credential must resolve to a user session in the client's tenant (
TENANT_MISMATCHotherwise). Sign the person in to that tenant first. - Consent cannot be granted while an administrator impersonates the
member (
IMPERSONATION_RESTRICTED). consent: falsefinishes the interaction withaccess_denied, which tells the client the person declined.
Never trust identity from the form
Pass the person's actual IAM (their session cookie or bearer token). Never pass account IDs or tenant IDs from a form as verified identity.
interactionDetails returns:
Prop
Type
The forms passed to renderDevicePage and renderLogoutPage carry the provider's CSRF fields. Embed them in your
page unchanged. The runnable example in examples/shared demonstrates the full browser flow.
Branding and first-party apps
Consent screens get the client's branding from interactionDetails(...).client: name, and the HTTPS logoUri,
clientUri, policyUri, and tosUri set at registration. They also get firstParty, which marks the
deployment's own applications. Asking people to "allow" your own mobile app to access their account is confusing,
so your host may approve consent for first-party apps without asking by calling completeInteraction with
consent: true once the person is signed in. The provider never skips the interaction by itself.
Register clients
A client is an application allowed to use the provider. There are two kinds:
- Confidential clients run on a server and can keep a secret: web apps with a backend, services.
- Public clients run where a secret would leak: single-page apps, mobile and desktop apps, CLIs. They rely on PKCE instead of a secret.
Clients are created with issuer.registerClient(credential, input), which requires iam:oauth:clients:create on
the oauth-client resource. A client's tenant and ID are immutable.
const { clientSecret } = await issuer.registerClient(credential, {
tenantId,
clientId: 'reports-web',
name: 'Reports',
redirectUris: ['https://reports.example/callback'],
postLogoutRedirectUris: ['https://reports.example/'],
scopes: ['openid', 'email', 'profile', 'offline_access', 'documents:read'],
logoUri: 'https://reports.example/logo.png',
policyUri: 'https://reports.example/privacy',
firstParty: true,
});
// A confidential client receives its random secret once. Store it now.Prop
Type
registerClient returns { clientId, clientSecret, tenantId }; clientSecret is only present for secret-based
confidential clients. Registered redirect URIs are exact. Client secrets and protocol payloads are encrypted at
rest, and token identifiers are hashed for storage.
Manage clients
After registration, these methods back an "Applications" page in your admin UI. Each checks the listed permission on the client:
| Method | Permission | What it does and when to use it |
|---|---|---|
listClients(credential, { tenantId, includeRevoked? }) | iam:oauth:clients:read per client | Lists the clients the caller may read, for an admin "Applications" page. Never returns a secret. |
getClient(credential, { tenantId, clientId }) | iam:oauth:clients:read | Reads one client's settings. |
updateClient(credential, { tenantId, clientId, ...settings }) | iam:oauth:clients:update | Changes settings, such as a new redirect URI, with the same validation as registration. |
rotateClientSecret(credential, { tenantId, clientId, revokeTokens? }) | iam:oauth:clients:update | Issues a new secret once and disables the previous one immediately. Use it on a schedule, or at once when a secret leaked. |
revokeClient(credential, { tenantId, clientId }) | iam:oauth:clients:delete | Retires an app: revokes the client and deletes every token, code, and grant issued to it. |
updateClient changes the name, redirect URIs, grant types, scopes, resources, requireDpop,
requirePushedAuthorization, lifetimes, branding, back-channel logout URI, and keys. Tenant, ID, client type, and
service account stay immutable. Pass null to remove a branding URL or the back-channel logout URI, or to restore
a default lifetime.
Pass revokeTokens: true to rotateClientSecret when the old secret leaked, so tokens obtained with it stop
working too. Client summaries include tokenEndpointAuthMethod, keyIds, jwksUri, registeredVia,
secretRotatedAt, and firstParty. Updates and rotations are audited as iam:oauth:UpdateClient and
iam:oauth:RotateClientSecret.
Key-based client authentication
A shared secret has to be stored by both sides and can leak. With private_key_jwt, the client keeps a private key
and proves itself by signing a short message instead, so the provider only ever holds its public key.
Confidential clients default to client_secret_basic. Register with tokenEndpointAuthMethod: 'client_secret_post' to send the secret in the form body, or 'private_key_jwt' with public keys in jwks or an
HTTPS jwksUri:
await issuer.registerClient(credential, {
tenantId,
clientId: 'ledger-api',
name: 'Ledger',
redirectUris: [],
grantTypes: ['client_credentials'],
serviceAccountId,
tokenEndpointAuthMethod: 'private_key_jwt',
jwks: { keys: [ledgerPublicJwk] },
});- A
private_key_jwtclient receives no secret. It gives eitherjwks(at most ten keys; private key members are refused) orjwksUri, not both. - It signs a short-lived assertion (
issandsub= client ID,aud= issuer, uniquejti) for each token, introspection, or revocation request, and each assertion works once. - Rotate keys with
updateClient({ jwks })or a newjwksUriwithout revoking tokens.
Consent and connected apps
Each consent is a grant: the record that a person allowed a client certain scopes. Repeating consent in the same provider session extends the existing grant, so an account has one grant per client instead of a new one per sign-in. Grants expire 30 days after the last consent.
Grants are what a "Connected apps" settings page shows, where people see which apps can access their account and disconnect the ones they no longer use:
| Method | What it does |
|---|---|
listGrants(credential, { tenantId, identityId?, clientId? }) | Lists live grants: client name, OIDC scopes and claims, resource scopes, creation, expiry. Revoked, expired, and session-orphaned grants are left out. |
revokeGrant(credential, { tenantId, grantId }) | Disconnects one app, by the grant's opaque id. |
revokeGrants(credential, { tenantId, identityId?, clientId? }) | Disconnects all of an account's apps, or one client's. Returns { revoked }. |
Revocation invalidates the grant's refresh tokens, access tokens, and codes at once and is audited as
iam:oauth:RevokeGrant. People manage their own grants without extra permissions. Reading or revoking another
account's grants, for example by support staff, requires iam:oauth:grants:read or iam:oauth:grants:revoke on
iam/{identityId} in that account's tenant.
Session binding
Every user grant is bound to the IAM session used to approve it. On every later token use the provider rechecks
session expiry, logout, account deactivation, tenant suspension, the current MFA policy, and configured idle
limits. This is why signing out of Better IAM, or being deactivated, also cuts off every app the person connected.
iam.protocolHost.validateSession supplies the exact product policy; standalone integrations without that
callback use a one-day idle maximum.
Refresh-token reuse permanently revokes the grant family, including tokens issued by a racing request: a reused refresh token means someone copied it. Database transactions protect individual artifact operations and do not hold a writer lock while waiting for request bodies.
Tokens and claims
A claim is one fact inside a token, such as the account's email. Tokens carry tenant_id; client-credentials
tokens also carry identity_id, the service account.
| Scope | Claims |
|---|---|
openid | sub (the account ID), tenant_id |
email | email, email_verified |
profile | name |
iam | roles, groups, attributes |
The built-in iam scope adds roles and groups (the account's live and
IDs when the claims are
read, expired bindings excluded) and attributes (declared identity attributes) to the userinfo response. Per the
standard rule, ID tokens issued alongside an access token carry only openid claims, so relying parties read
these from userinfo. They can render navigation or map roles without a callback. The values are snapshots, and
enforcement stays with Better IAM.
Scopes are not permissions
OAuth scopes are client-facing claims, not IAM permission grants. Resource servers should introspect or verify tokens, check their tenant and scope, and apply their product's authorization . OAuth tokens are not accepted as IAM administrative session credentials by default.
Token lifetimes
| Artifact | Lifetime |
|---|---|
| Access token | 15 minutes, or the target resource server's accessTokenTtl. A client's accessTokenTtl (60 to 86,400 seconds) can only shorten it. |
| Refresh token | The client's refreshTokenTtl (5 minutes to 30 days, default 30 days). Restarts on every rotation, but never outlives the consent. |
| Consent (grant) | 30 days from the last consent. |
| Authorization code | 60 seconds. |
| Pushed authorization request | 60 seconds. |
| Device code, interaction | 10 minutes. |
| ID token | 15 minutes. |
Short access tokens limit the damage of a leaked token; refresh tokens keep the experience smooth. Pass null to
updateClient to restore a default lifetime.
Pushed authorization requests
Normally a client puts its whole authorization request (client ID, scopes, redirect URI) in the browser URL, where
it can be read or tampered with and can grow too long. With pushed authorization requests (PAR, RFC 9126), the
client first posts those parameters directly to the server at the discovered
pushed_authorization_request_endpoint, gets back a short-lived request_uri, and sends only that through the
browser.
Require PAR for every client with requirePushedAuthorizationRequests: true, or per client with
requirePushedAuthorization, for example for high-value integrations. Pushed requests expire after 60 seconds and
cannot use unregistered redirect URIs.
Back-channel logout
When a person signs out of Better IAM, apps that created their own sessions from its tokens still consider them signed in. Back-channel logout (an OpenID standard) fixes that: the provider calls each app's server directly with a signed logout token, and the app ends its own session.
Register an HTTPS backchannelLogoutUri on a client to receive them. logoutEndedSessions({ identityId? }) does
the work:
- It finds consents whose IAM session expired, was revoked, or belongs to an account or tenant that is no longer active.
- It posts a logout token signed with the provider keys (
sub= account,aud= client, back-channel logout event) to each client once per account. - It revokes the grants and their tokens, and audits
iam:oauth:SessionLogout. - It returns
{ sessions, grants, notified, failures }.
Deliveries time out after 2.5 seconds and are not retried. Sessions expire without an event, so call
logoutEndedSessions() on an interval as well as after sign-out and deactivation events:
iam.events.subscribe(['auth:session:*', 'identity:*', 'tenant:*'], () =>
issuer.logoutEndedSessions(),
);
setInterval(() => void issuer.logoutEndedSessions(), 60_000).unref();Outbound requests
Outbound requests (client JWKS URIs and logout deliveries) refuse private and special-use addresses, so a client
cannot point the provider at your internal network. allowInsecureLocalhost exempts loopback targets for local
development only.
See Protocol jobs for running this job in production alongside the other scheduled work.
Audit events
Every administrative change and every consent is recorded in the tenant's audit chain, so you can answer "who connected this app" or "who rotated this secret":
| Action | When |
|---|---|
iam:oauth:RegisterClient | A client was registered by an administrator or through dynamic registration. |
iam:oauth:UpdateClient, iam:oauth:RotateClientSecret, iam:oauth:RevokeClient | Client administration. |
iam:oauth:Consent | A person approved a client. |
iam:oauth:RevokeGrant | A grant was revoked. |
iam:oauth:SessionLogout | logoutEndedSessions ended a client's grants for an account. |
iam:oauth:TokenExchange | A token was exchanged (RFC 8693). |
iam:oauth:CreateRegistrationToken, iam:oauth:RevokeRegistrationToken | Registration token administration. |
Next steps
Better IAM is created by Sean Filimon
Last updated
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.
Resource servers and tokens
Issue audience-restricted JWT access tokens for your APIs, verify them offline, bind them to keys with DPoP, and delegate calls with token exchange.