npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@startino/better-auth-oidc

v0.1.10

Published

OIDC-only SSO plugin for Better Auth with no Node.js API dependencies

Readme

@startino/better-auth-oidc

OIDC-only SSO plugin for Better Auth. Runs on any JavaScript runtime without Node.js-specific APIs.

Why this exists

The official @better-auth/sso plugin imports samlify at module load, which requires Node.js-only APIs (node:crypto, node:buffer). This breaks in edge runtimes, serverless environments, or any platform without full Node.js compatibility, even if you only need OIDC.

This package extracts the OIDC code paths into a standalone plugin. SAML code and Node.js dependencies are removed entirely.

Runtime compatibility

| Runtime | @better-auth/sso | @startino/better-auth-oidc | |---|---|---| | Node.js | Yes | Yes | | Convex | No | Yes | | Cloudflare Workers | No | Yes | | Deno | No | Yes | | Bun | Yes | Yes | | Vercel Edge | No | Yes |

Install

# bun
bun add @startino/better-auth-oidc

# npm
npm install @startino/better-auth-oidc

# pnpm
pnpm add @startino/better-auth-oidc

Peer dependencies: better-auth (>=1.4.0), better-call (>=1.0.0).

Quick start

Server

import { betterAuth } from "better-auth";
import { oidcSso } from "@startino/better-auth-oidc";

export const auth = betterAuth({
  // ... your config
  plugins: [
    oidcSso({
      // Optional: provision users into orgs on first sign-in
      organizationProvisioning: {
        defaultRole: "member",
      },
      // Optional: verify domain ownership via DNS TXT records
      domainVerification: {
        enabled: true,
      },
    }),
  ],
});

Client

import { createAuthClient } from "better-auth/client";
import { oidcSsoClient } from "@startino/better-auth-oidc/client";

const client = createAuthClient({
  plugins: [
    oidcSsoClient({
      // Must match server config
      domainVerification: { enabled: true },
    }),
  ],
});

// Register an OIDC provider
await client.sso.register({
  providerId: "okta-acme",
  issuer: "https://acme.okta.com",
  domain: "acme.com",
  oidcConfig: {
    clientId: "your-client-id",
    clientSecret: "your-client-secret",
  },
});

// Sign in with SSO
await client.signIn.sso({
  email: "[email protected]",
  callbackURL: "/dashboard",
});

SSO flow overview

Here is what happens during an SSO sign-in:

┌────────┐            ┌─────────────┐            ┌─────┐            ┌─────┐
│ Client │            │ Auth Server │            │ IdP │            │ App │
└───┬────┘            └──────┬──────┘            └──┬──┘            └──┬──┘
    │                        │                      │                  │
    │  POST /sign-in/sso     │                      │                  │
    │───────────────────────>│                      │                  │
    │                        │                      │                  │
    │                        │  302 → authorize     │                  │
    │                        │─────────────────────>│                  │
    │                        │                      │                  │
    │                        │  User logs in        │                  │
    │                        │                      │                  │
    │                        │  GET /sso/callback   │                  │
    │                        │<─────────────────────│                  │
    │                        │                      │                  │
    │                        │  Exchange code       │                  │
    │                        │  for tokens          │                  │
    │                        │─────────────────────>│                  │
    │                        │<─────────────────────│                  │
    │                        │                      │                  │
    │                        │  Create/link user    │                  │
    │                        │  Create session      │                  │
    │                        │  Generate OTT        │                  │
    │                        │                      │                  │
    │                        │       302 → callbackURL?ott=TOKEN       │
    │                        │────────────────────────────────────────>│
    │                        │                      │                  │
    │                        │       GET /sso/verify-ott?token=…       │
    │                        │<────────────────────────────────────────│
    │                        │                      │                  │
    │                        │    Set session cookie on app domain     │
    │                        │────────────────────────────────────────>│
    │                        │                      │                  │
    ▼                        ▼                      ▼                  ▼
  1. Sign-in request. The client calls POST /sign-in/sso with an email, domain, or provider ID. The plugin finds the matching SSO provider and builds the OIDC authorization URL.
  2. Redirect to IdP. The user is redirected to the identity provider (Okta, Entra ID, Google Workspace, etc.) to authenticate.
  3. Callback. The IdP redirects back to GET /sso/callback/:providerId with an authorization code.
  4. Token exchange. The plugin exchanges the code for an ID token and (optionally) access token. The ID token is verified against the provider's JWKS.
  5. User creation/linking. A user account is created or linked. Organization membership is assigned if configured.
  6. OTT generation. A one-time token is created (32 random characters, 60-second TTL) and appended to the callback URL as ?ott=TOKEN.
  7. OTT verification. The app calls GET /sso/verify-ott?token=TOKEN through its proxy to the auth server. This sets the session cookie on the app's domain.

Cross-domain session transfer (OTT)

The problem

In proxy-based architectures (SvelteKit + Convex, Next.js + external auth server, etc.), the auth server runs on a different domain than your app. The SSO callback hits the auth server's domain, so the session cookie is set there, not on the app's domain.

Without OTT, the user would be redirected back to the app with no session.

How it works

After the SSO callback processes the sign-in, the plugin:

  1. Creates a session and sets the cookie on the auth server's domain
  2. Generates a one-time token (32 characters, stored in the verification table, expires in 60 seconds)
  3. Redirects to your callbackURL with ?ott=TOKEN appended

Your app then verifies the OTT through its auth proxy, which sets the session cookie on the correct domain.

Client-side implementation

On whichever page your callbackURL points to, check for the ott query parameter and verify it:

// Example: SvelteKit +page.ts or +page.server.ts
import { redirect } from "@sveltejs/kit";

export const load = async ({ url, fetch }) => {
  const ott = url.searchParams.get("ott");
  if (ott) {
    // Call through your proxy so the cookie is set on your domain
    const res = await fetch(`/api/auth/sso/verify-ott?token=${ott}`);
    if (!res.ok) {
      throw redirect(303, "/signin?error=sso-verification-failed");
    }
    // Remove the ott param from the URL
    throw redirect(303, url.pathname);
  }
};
// Example: Next.js middleware or page
const ott = searchParams.get("ott");
if (ott) {
  await fetch(`${process.env.NEXT_PUBLIC_URL}/api/auth/sso/verify-ott?token=${ott}`, {
    credentials: "include",
  });
  redirect("/dashboard");
}

The key requirement: the verify-ott call must go through your app's proxy to the auth server so the Set-Cookie header lands on your app's domain.

Avoiding ott param conflicts

Some auth libraries (notably convex-better-auth-svelte) auto-intercept ?ott= in the URL and try to verify it through Better Auth's built-in cross-domain plugin. This is a different endpoint and token format than this plugin's /sso/verify-ott. If the cross-domain plugin isn't installed, this causes an unhandled error.

Fix: Strip or rename the ott param server-side before it reaches the client. In SvelteKit, use a +page.server.ts on your callback page:

// +page.server.ts on your SSO callback page
import { redirect } from "@sveltejs/kit";

export const load = async ({ url, params }) => {
  const ott = url.searchParams.get("ott");
  if (!ott) return {};

  // Rename ott to sso_ott so other libraries don't intercept it.
  // Your client-side code reads sso_ott instead.
  throw redirect(302, `/${params.org}/signin?sso_callback=true&sso_ott=${encodeURIComponent(ott)}`);
};

Then read sso_ott in your client-side callback handler instead of ott.

If your callback page doesn't need OTT verification at all (e.g. the user is already authenticated and you're only re-verifying their identity), strip the ott entirely:

// +page.server.ts — just drop the ott param
import { redirect } from "@sveltejs/kit";

export const load = async ({ url, params }) => {
  if (url.searchParams.get("callback") === "true" && url.searchParams.get("ott")) {
    throw redirect(302, `/${params.org}/sso-verify?callback=true`);
  }
  return {};
};

Configuring error redirects for cross-domain setups

When the auth server runs on a different domain than your app, Better Auth's OAuth error handling (expired state, state mismatch, PKCE failures) redirects to ${baseURL}/error. If baseURL points to the auth server, users see a raw error page with no UI.

Set onAPIError.errorURL in your Better Auth config to redirect errors to your app instead:

const auth = betterAuth({
  baseURL: process.env.AUTH_SERVER_URL,
  onAPIError: {
    errorURL: process.env.APP_URL, // e.g. "https://myapp.com"
  },
  plugins: [oidcSso()],
});

Then handle the ?error= query param on your app:

// In your root layout or error handler
const error = new URL(window.location.href).searchParams.get("error");
if (error) {
  // Show a user-friendly message. Common errors:
  // - please_restart_the_process (expired OAuth state)
  // - state_mismatch (state verification failed)
  // - invalid_state (corrupted state data)
  showError("Sign-in session expired. Please try again.");
}

Configuration options

SSOOptions

| Option | Type | Default | Description | |---|---|---|---| | provisionUser | (data) => void | - | Called after a user signs in or signs up via SSO. Receives { user, userInfo, token, provider }. Use for custom role assignment, feature flags, syncing to external systems, etc. | | organizationProvisioning | object | - | Auto-assign users to organizations based on SSO provider. See Organization provisioning. | | defaultSSO | Array<{ domain, providerId, oidcConfig? }> | - | Default provider configs (takes precedence over DB). Useful for development/testing without storing providers in the database. | | defaultOverrideUserInfo | boolean | false | Override user name/image with provider data on every sign-in, not just the first. | | disableImplicitSignUp | boolean | false | Reject new users unless requestSignUp: true is passed in the sign-in call. | | modelName | string | "ssoProvider" | Custom table name for SSO providers in the database. | | fields | object | - | Remap column names: { issuer?, oidcConfig?, userId?, providerId?, organizationId?, domain? }. | | providersLimit | number \| (user) => number | 10 | Max providers a user can register. Set to 0 to disable registration entirely. | | trustEmailVerified | boolean | false | Trust the email_verified claim from the IdP. Deprecated. See Account linking. | | domainVerification | object | - | DNS-based domain ownership verification. See Domain verification. | | domainVerification.enabled | boolean | false | Enable/disable domain verification. | | domainVerification.tokenPrefix | string | "better-auth-token" | Prefix for the DNS TXT record. |

OIDC configuration

When registering or updating a provider, the oidcConfig object controls how the plugin communicates with the identity provider.

| Field | Type | Required | Default | Description | |---|---|---|---|---| | clientId | string | Yes | - | OAuth client ID from your IdP. | | clientSecret | string | Yes | - | OAuth client secret from your IdP. | | issuer | string | - | Parent issuer | The OIDC issuer URL. Usually set on the provider, not inside oidcConfig. | | discoveryEndpoint | string | No | {issuer}/.well-known/openid-configuration | Override the discovery URL if your IdP uses a non-standard path. | | skipDiscovery | boolean | No | false | Skip automatic OIDC discovery. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually. | | authorizationEndpoint | string | If skipDiscovery | Discovered | OAuth authorization URL. | | tokenEndpoint | string | If skipDiscovery | Discovered | OAuth token exchange URL. | | jwksEndpoint | string | If skipDiscovery | Discovered | JWKS URL for ID token signature verification. | | userInfoEndpoint | string | No | Discovered | UserInfo endpoint. Only needed if ID token claims are insufficient. | | tokenEndpointAuthentication | "client_secret_basic" \| "client_secret_post" | No | Auto-detected | How to authenticate at the token endpoint. client_secret_basic sends credentials in the Authorization header. client_secret_post sends them in the request body. The plugin auto-selects based on IdP discovery, preferring client_secret_basic. | | pkce | boolean | No | true | Use PKCE (Proof Key for Code Exchange) with S256 challenge method. Recommended to leave enabled. | | scopes | string[] | No | ["openid", "email", "profile"] | OAuth scopes to request. | | overrideUserInfo | boolean | No | false | Override stored user info with provider data on this specific provider. | | mapping | OIDCMapping | No | See below | Map non-standard claim names to expected fields. |

Field mapping

If your IdP returns claims with non-standard names, use the mapping object to tell the plugin where to find each field.

oidcConfig: {
  clientId: "...",
  clientSecret: "...",
  mapping: {
    id: "sub",                // Default: "sub"
    email: "email",           // Default: "email"
    emailVerified: "email_verified", // Default: "email_verified"
    name: "name",             // Default: "name"
    image: "picture",         // Default: "picture"
    extraFields: {            // Map additional claims
      department: "custom:department",
      employeeId: "custom:employee_id",
    },
  },
}

The extraFields values are passed through to the provisionUser callback in userInfo.

Endpoints

| Endpoint | Method | Auth | Description | |---|---|---|---| | /sso/register | POST | Yes | Register a new OIDC provider. | | /sign-in/sso | POST | No | Initiate SSO sign-in. Accepts email, providerId, domain, or organizationSlug to find the provider. Returns { url, redirect: true }. | | /sso/callback/:providerId | GET | No | OAuth2 callback handler. Exchanges code for tokens, creates/links user, generates OTT, redirects to callbackURL. | | /sso/verify-ott | GET | No | Exchange a one-time token for a session cookie. Query: ?token=TOKEN. | | /sso/providers | GET | Yes | List providers the authenticated user has access to. | | /sso/get-provider | GET | Yes | Get a single provider. Query: ?providerId=ID. | | /sso/update-provider | POST | Yes | Update provider fields (partial update). Changing domain resets domainVerified. | | /sso/delete-provider | POST | Yes | Delete a provider. Body: { providerId }. | | /sso/request-domain-verification | POST | Yes | Request a domain verification token (if enabled). | | /sso/verify-domain | POST | Yes | Verify domain via DNS TXT record lookup (if enabled). |

Sign-in options

The POST /sign-in/sso endpoint accepts these fields:

| Field | Type | Required | Description | |---|---|---|---| | email | string | One of these | Extracts the domain to find a matching provider. | | providerId | string | | Direct provider reference. | | domain | string | | Direct domain reference. | | organizationSlug | string | | Finds the org, then finds a provider linked to it. | | callbackURL | string | Yes | Where to redirect after sign-in. The OTT is appended here. | | errorCallbackURL | string | No | Redirect on error. Falls back to callbackURL. | | newUserCallbackURL | string | No | Redirect for first-time users. Falls back to callbackURL. | | scopes | string[] | No | Override the provider's default scopes for this sign-in. | | loginHint | string | No | Pre-fill the email/username at the IdP login screen. | | requestSignUp | boolean | No | Required when disableImplicitSignUp is true. |

Organization provisioning

If you use Better Auth's organization plugin, you can auto-assign users to organizations when they sign in via SSO.

oidcSso({
  organizationProvisioning: {
    defaultRole: "member",         // "member" or "admin"
    getRole: async ({ user, userInfo, token, provider }) => {
      // Custom logic, e.g. check a group claim
      const groups = userInfo["groups"] || [];
      return groups.includes("admins") ? "admin" : "member";
    },
  },
});

How it works:

  1. When registering a provider, pass organizationId to link it to an org.
  2. On sign-in, after the user account is created or linked, the plugin checks if the provider has an organizationId.
  3. If the user is not already a member of that org, they are added with the role from getRole() (or defaultRole if no function provided).

Set organizationProvisioning.disabled: true to turn off auto-provisioning while keeping the config.

provisionUser callback

For more control beyond org assignment, use provisionUser. It runs after the user is created/linked and after org assignment.

oidcSso({
  provisionUser: async ({ user, userInfo, token, provider }) => {
    // Sync to your own database, assign feature flags, send a welcome email, etc.
    await myDb.upsertUser({
      id: user.id,
      department: userInfo.department,
      ssoProvider: provider.providerId,
    });
  },
});

The token parameter contains the OAuth2 tokens (access token, refresh token, ID token) from the provider if you need to make further API calls.

Multi-domain SSO

A single SSO provider can serve multiple email domains. Pass a comma-separated string:

await client.sso.register({
  providerId: "acme-corp",
  issuer: "https://acme.okta.com",
  domain: "acme.com,subsidiary.com,acquired-co.com",
  oidcConfig: { clientId: "...", clientSecret: "..." },
});

When a user signs in with [email protected], the plugin finds the provider by checking each domain in the list. Subdomain matching is also supported: [email protected] matches acme.com.

The domainMatches(searchDomain, domainList) utility function handles this logic. It splits on commas, trims whitespace, and checks for exact or subdomain matches (case-insensitive).

Domain verification

When domainVerification.enabled is true, new providers require DNS-based domain ownership proof before sign-ins are allowed.

Setup flow

  1. Register a provider. The response includes domainVerificationToken and domainVerified: false.

  2. Create a DNS TXT record:

    Name:  _better-auth-token-<providerId>.<domain>
    Value: _better-auth-token-<providerId>=<token>

    Example for provider okta-acme on acme.com:

    _better-auth-token-okta-acme.acme.com  TXT  "_better-auth-token-okta-acme=abc123xyz..."
  3. Call the verify endpoint. The plugin resolves the TXT record via DNS-over-HTTPS (Cloudflare) and confirms ownership.

No node:dns required. Verification works on any runtime with fetch.

How DNS verification works

  • The plugin queries https://cloudflare-dns.com/dns-query with the TXT record name.
  • The record name follows RFC 8552 (underscore-prefixed labels): _{tokenPrefix}-{providerId}.{domain}.
  • The token prefix defaults to better-auth-token. You can change it in the config.
  • Verification tokens last 7 days. You can re-request them if expired.
  • If the domain changes on a provider, domainVerified resets to false.
  • DNS labels are capped at 63 characters. The plugin validates this before lookup.

OIDC discovery

By default, the plugin fetches the provider's /.well-known/openid-configuration document when you register a provider. This auto-fills:

  • authorizationEndpoint
  • tokenEndpoint
  • jwksEndpoint
  • userInfoEndpoint
  • tokenEndpointAuthentication (selected from token_endpoint_auth_methods_supported)

If the provider's discovery document is missing required fields (issuer, authorization_endpoint, token_endpoint, jwks_uri), registration fails with a descriptive error.

Skipping discovery

Some providers don't support standard OIDC discovery. Set skipDiscovery: true and provide endpoints manually:

oidcConfig: {
  clientId: "...",
  clientSecret: "...",
  skipDiscovery: true,
  authorizationEndpoint: "https://idp.example.com/authorize",
  tokenEndpoint: "https://idp.example.com/token",
  jwksEndpoint: "https://idp.example.com/.well-known/jwks.json",
}

Custom discovery endpoint

If your IdP uses a non-standard discovery path:

oidcConfig: {
  clientId: "...",
  clientSecret: "...",
  discoveryEndpoint: "https://idp.example.com/custom/.well-known/openid-configuration",
}

Runtime discovery

If the stored config is missing tokenEndpoint or jwksEndpoint at sign-in time, the plugin re-runs discovery to fill them in. This handles cases where you registered a provider before certain fields were required.

Account linking and trustEmailVerified

When a user signs in via SSO with an email that already exists in your database, Better Auth's account linking determines what happens.

The trustEmailVerified option on this plugin controls whether the email_verified claim from the IdP is passed to Better Auth as the user's emailVerified field. If true, and the IdP says the email is verified, Better Auth may auto-link the account (depending on your accountLinking config).

This option is deprecated. The IdP's email_verified claim is a weak trust signal. Instead:

  • Use domainVerification: { enabled: true } to verify that the SSO provider actually owns the domain.
  • Configure Better Auth's accountLinking.trustedProviders to trust specific providers.
  • Or set up accountLinking.allowDifferentEmails per your needs.

Migration from @better-auth/sso

| @better-auth/sso | @startino/better-auth-oidc | |---|---| | import { sso } from "@better-auth/sso" | import { oidcSso } from "@startino/better-auth-oidc" | | import { ssoClient } from "@better-auth/sso/client" | import { oidcSsoClient } from "@startino/better-auth-oidc/client" | | sso({ ... }) | oidcSso({ ... }) | | ssoClient({ ... }) | oidcSsoClient({ ... }) | | Plugin ID: "sso" | Plugin ID: "oidc-sso" | | samlConfig in options/schema | Removed (OIDC only) | | saml options block | Removed | | defaultSSO[].samlConfig | Removed | | fields.samlConfig | Removed |

The database schema is the same minus the samlConfig column. If migrating from @better-auth/sso, you can drop the samlConfig column from your ssoProvider table, or leave it (it will be ignored).

Exported utilities

The package exports OIDC discovery functions and types for advanced use cases:

Discovery functions

| Export | Description | |---|---| | discoverOIDCConfig(params) | Main entry point. Fetches and hydrates OIDC config from an issuer URL. | | computeDiscoveryUrl(issuer) | Returns {issuer}/.well-known/openid-configuration. | | fetchDiscoveryDocument(url, timeout?) | Fetches and parses a discovery document. Default timeout: 10 seconds. | | validateDiscoveryUrl(url, isTrustedOrigin) | Validates that a discovery URL is trusted. | | validateDiscoveryDocument(doc, issuer) | Checks required fields and issuer match. | | normalizeDiscoveryUrls(doc, issuer, isTrustedOrigin) | Validates and normalizes all endpoint URLs in a discovery document. | | normalizeUrl(name, endpoint, issuer) | Normalizes a single URL, resolving relative paths against the issuer. | | selectTokenEndpointAuthMethod(doc, existing?) | Picks the best token endpoint auth method from a discovery document. | | needsRuntimeDiscovery(config) | Returns true if the config is missing tokenEndpoint or jwksEndpoint. | | mapDiscoveryErrorToAPIError(error) | Converts a DiscoveryError to a Better Auth APIError. | | REQUIRED_DISCOVERY_FIELDS | Array of required fields: issuer, authorization_endpoint, token_endpoint, jwks_uri. |

Types

| Export | Description | |---|---| | OIDCConfig | Full OIDC provider configuration object. | | SSOOptions | Plugin configuration options. | | SSOProvider | SSO provider record (conditional on domainVerification). | | OIDCDiscoveryDocument | OpenID Connect Discovery 1.0 document shape. | | HydratedOIDCConfig | Discovery-resolved config with all endpoints filled in. | | DiscoverOIDCConfigParams | Parameters for discoverOIDCConfig(). | | DiscoveryErrorCode | Union of discovery error codes. | | DiscoveryError | Error class with code and details. | | RequiredDiscoveryField | Union type of required discovery field names. | | OIDCSSOPlugin | Plugin type for type inference. |

Credits

This package is an OIDC-only extraction of @better-auth/sso by Bereket Engida. All OIDC logic, discovery pipeline, organization linking, and provider management code originates from that package.

License

MIT