BluAuth
Docs
Sign in
User FAQ
  • Reset my password
  • I can't sign in
  • Didn't get reset email
  • Account linking
  • Session expiry
  • Two-factor auth
Admin Guides
Theme Studio
  • Overview
  • Layouts
  • Styling tokens
  • Concept copy
  • Assets & backgrounds
  • Advanced CSS
Admin Shell
  • Users
  • Providers
  • Clients
  • Invitations
Integrations
  • OIDC flow
  • Legacy OAuth flow
  • Provider token brokering
  • Email triggers
  • Webhook events
  • Session contract
Reference
  • API
  • Error codes
  • Event shapes
  • Design tokens
Runbooks
  • Deployment
  • Local operations

OIDC Integration

BluAuth is a standards-compliant OpenID Connect provider. Downstream Blu apps sign users in by redirecting them to BluAuth, exchanging a one-time authorization code for signed tokens, and validating the ID token. This page walks through the full flow and documents the exact parameters, claims, and error conditions a production client must handle.

Any greenfield Blu app must integrate via this path. The legacy /oauth2/* broker exists only to support pre-existing apps during migration.

Discovery

BluAuth publishes a spec-compliant discovery document:

GET https://auth.example.com/.well-known/openid-configuration
{
    "issuer": "https://auth.example.com",
    "authorization_endpoint": "https://auth.example.com/api/oidc/authorize",
    "token_endpoint": "https://auth.example.com/api/oidc/token",
    "userinfo_endpoint": "https://auth.example.com/api/oidc/userinfo",
    "jwks_uri": "https://auth.example.com/api/oidc/jwks",
    "introspection_endpoint": "https://auth.example.com/api/oidc/token/introspect",
    "revocation_endpoint": "https://auth.example.com/api/oidc/token/revoke",
    "end_session_endpoint": "https://auth.example.com/api/oidc/end-session",
    "scopes_supported": ["openid", "profile", "email", "admin"],
    "response_types_supported": ["code"],
    "grant_types_supported": ["authorization_code", "client_credentials"],
    "subject_types_supported": ["pairwise"],
    "id_token_signing_alg_values_supported": ["ES256"],
    "token_endpoint_auth_methods_supported": ["private_key_jwt"],
    "code_challenge_methods_supported": ["S256"]
}

Your OIDC client library should consume the discovery document and reuse its URLs. Do not hardcode endpoints — they are versioned with the service.

Source: server/routes/.well-known/openid-configuration.get.ts.

Register a client

Before integrating, ask a BluAuth admin to create an OAuth client for your app (see /admin/clients). You will receive:

  • Client ID — a UUID. Used as client_id and as the aud claim in ID tokens.
  • Client authentication — either private_key_jwt (recommended confidential clients) or PKCE-only (public clients / SPAs).
  • Allowed scopes — typically openid profile email. admin is reserved for service clients.
  • Redirect URIs — BluAuth rejects any redirect_uri that does not exactly match one of the registered values.
  • Post-logout redirect URIs — allow-listed separately from redirect URIs.

The authorization code flow with PKCE

PKCE is required for every client, public or confidential. BluAuth rejects any code_challenge_method other than S256, and rejects authorization requests without a nonce.

1. Generate PKCE values and anti-forgery state

import { randomBytes, createHash } from 'node:crypto';

const codeVerifier = randomBytes(64).toString('base64url'); // 43–128 chars
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
const state = randomBytes(32).toString('base64url');
const nonce = randomBytes(32).toString('base64url');

Persist codeVerifier, state, and nonce in a server-side session keyed to the browser (or a signed, HttpOnly cookie). The values must survive the round-trip to BluAuth.

2. Redirect the user to the authorization endpoint

GET https://auth.example.com/api/oidc/authorize
  ?client_id=7f9b0e7e-...-a11c
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback
  &response_type=code
  &scope=openid%20profile%20email
  &state=<opaque>
  &nonce=<opaque>
  &code_challenge=<pkce-challenge>
  &code_challenge_method=S256

Required parameters:

ParameterValue
client_idRegistered client UUID.
redirect_uriMust exactly match one of the client's registered URIs.
response_typecode. Any other value returns unsupported_response_type.
scopeSpace-separated. Must be a subset of the client's allowedScopes.
stateRandom opaque value; echoed back on callback.
nonceRandom opaque value; mirrored into the ID token's nonce claim.
code_challengeBase64url-encoded SHA-256 of the code verifier.
code_challenge_methodS256.

Source: server/api/oidc/authorize.get.ts.

3. BluAuth handles the user

  • If the user has an active BluAuth session, BluAuth issues an authorization code and redirects back immediately (silent re-auth).
  • Otherwise, BluAuth renders the hosted login page, completes sign-in (local password or federated), and then issues the code.
  • If the user requires a forced password reset, BluAuth sends them through /reset-password first, returning to the authorize flow on completion.
  • If the user denies consent or cancels, BluAuth redirects to redirect_uri with ?error=access_denied&state=<echoed>.

4. Exchange the code at the token endpoint

POST https://auth.example.com/api/oidc/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<code>
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback
&client_id=7f9b0e7e-...-a11c
&code_verifier=<pkce-verifier>
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=<signed-jwt>

Confidential clients authenticate with private_key_jwt (the canonical BluAuth method — see token_endpoint_auth_methods_supported). The client_assertion is a JWT signed with the client's registered key, with iss and sub set to the client ID and aud set to the token endpoint URL.

Public clients omit client_assertion / client_secret and rely on PKCE alone.

Successful response (tokens trimmed for readability):

{
    "access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImJsdWF1dGgtMTcx...",
    "id_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImJsdWF1dGgtMTcx...",
    "token_type": "Bearer",
    "expires_in": 3600
}

Both tokens are ES256-signed JWTs (EC P-256, alg: "ES256"). expires_in is fixed at 3600 seconds (1 hour). Response headers include Cache-Control: no-store and Pragma: no-cache per RFC 6749 §5.1.

Source: server/api/oidc/token.post.ts, server/utils/oidc/tokens.ts.

5. Validate the ID token

Validate, in order:

  1. Signature. Fetch the JWKS from /api/oidc/jwks, select the key matching the JWT's kid, and verify with ES256.
  2. iss equals the issuer returned by discovery (e.g. https://auth.example.com).
  3. aud equals your client ID.
  4. exp is in the future; iat is not in the future (allow 2 minutes of skew).
  5. nonce equals the value you sent in step 2.
  6. at_hash matches the left-most 128 bits of SHA-256 of the access token, base64url-encoded.

Signing keys rotate on a schedule; consumers that hardcode JWKS responses will break. Any mature OIDC client library performs these checks correctly — use one.

6. Call userinfo (optional)

GET https://auth.example.com/api/oidc/userinfo
Authorization: Bearer <access_token>
{
    "sub": "6f2a8e7e-...-pairwise",
    "name": "Jane Doe",
    "given_name": "Jane",
    "family_name": "Doe",
    "picture": "https://cdn.example.com/u/jane.png",
    "email": "jane@example.com",
    "email_verified": true,
    "emails": ["jane@example.com", "jane.doe@example.com"],
    "auth_method": "password",
    "linked_providers": ["google"],
    "current_provider": "credential",
    "mfa_satisfied": true,
    "auth_assurance_level": "aal2",
    "assurance_source": "local_2fa"
}

Returned claims are scoped — you only get profile claims if the profile scope was granted, and only email claims if email was granted. sub is pairwise — the same user has a different sub for each client, derived deterministically. emails lists all verified aliases so downstream apps can match on any of them.

The current_provider, mfa_satisfied, auth_assurance_level, and assurance_source claims describe the current sign-in event, not the user's long-term account shape. Downstream apps can use them for admission control such as provider-constrained access rules or current-session MFA requirements.

Source: server/utils/oidc/userinfo.ts.

ID token claims

ClaimNotes
issIssuer, matches discovery.
subPairwise per (user, client). Stable across sessions.
audYour client ID.
exp, iatExpiry and issue time (Unix seconds). TTL is 1 hour.
nonceEchoed from authorize request.
at_hashLeft half of SHA-256 of the access token.
auth_methodpassword, <provider-slug>, or service.
linked_providersArray of provider slugs the user has linked.
current_providerProvider that authenticated the current session.
mfa_satisfiedtrue, false, or null for the current session.
auth_assurance_levelAssurance summary such as aal1, aal2, or null.
assurance_sourceHow BluAuth derived assurance for the session.
email, email_verified, emailsPresent when email scope granted.
name, given_name, family_name, picturePresent when profile scope granted.

Refreshing tokens

BluAuth does not issue OAuth refresh tokens to downstream apps today. When an access token expires, your client should re-enter the authorize flow. If the user's BluAuth session is still active, BluAuth issues a new code without user interaction (silent re-auth via prompt=none is supported).

Plan for this in UI code: when a token expires mid-session, redirect to /api/oidc/authorize in a hidden iframe (or via full navigation), complete the code exchange, and resume.

Logout

RP-initiated logout follows OIDC RP-Initiated Logout 1.0:

GET https://auth.example.com/api/oidc/end-session
  ?id_token_hint=<id_token>
  &post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2Fsigned-out
  &state=<opaque>

BluAuth clears the Better Auth session cookie, deletes the session row from Postgres, and redirects to post_logout_redirect_uri (which must be pre-registered on the client). See Session Contract for full logout semantics.

Source: server/api/oidc/end-session.get.ts.

Revocation and introspection

BluAuth implements RFC 7009 (Token Revocation) and RFC 7662 (Token Introspection). Both require client authentication.

POST https://auth.example.com/api/oidc/token/revoke
Content-Type: application/x-www-form-urlencoded

token=<access_token>&token_type_hint=access_token
POST https://auth.example.com/api/oidc/token/introspect
Content-Type: application/x-www-form-urlencoded

token=<access_token>
{
    "active": true,
    "sub": "6f2a8e7e-...-pairwise",
    "client_id": "7f9b0e7e-...-a11c",
    "scope": "openid profile email",
    "token_type": "Bearer",
    "exp": 1744823025,
    "iat": 1744819425
}

Edge cases

  • State mismatch on callback. Reject the response and restart the flow. Never trust an unvalidated state.
  • Expired code. Codes are valid for 10 minutes. Attempting to exchange an expired code returns invalid_grant; the code is deleted from the store on detection.
  • Reused code. Codes are single-use. The first successful exchange deletes the row; the second attempt returns invalid_grant.
  • PKCE verifier mismatch. Returns invalid_grant. Ensure you're sending the exact verifier you generated — not the challenge.
  • Scope escalation. Requesting a scope outside allowedScopes aborts the flow at /authorize with invalid_scope.
  • Changing redirect_uri mid-flow. The value in step 4 must exactly match step 2. Case-sensitive, query-string-sensitive.

Example client (Node + openid-client)

import { Issuer, generators } from 'openid-client';

const issuer = await Issuer.discover('https://auth.example.com');

const client = new issuer.Client(
    {
        client_id: '7f9b0e7e-...-a11c',
        redirect_uris: ['https://app.example.com/auth/callback'],
        response_types: ['code'],
        token_endpoint_auth_method: 'private_key_jwt',
        id_token_signed_response_alg: 'ES256'
    },
    {
        keys: [
            /* your JWK */
        ]
    }
);

// Redirect
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
const state = generators.state();
const nonce = generators.nonce();

const authUrl = client.authorizationUrl({
    scope: 'openid profile email',
    code_challenge,
    code_challenge_method: 'S256',
    state,
    nonce
});

// Callback
const params = client.callbackParams(req);
const tokenSet = await client.callback('https://app.example.com/auth/callback', params, { code_verifier, state, nonce });

const claims = tokenSet.claims();

Errors

BluAuth returns spec-compliant OAuth 2.0 error responses on the authorize endpoint (query-string redirect) and on the token endpoint (JSON body).

ErrorMeaning
invalid_requestMissing or malformed parameter.
unauthorized_clientClient exists but isn't allowed for this grant or scope.
access_deniedUser cancelled, denied consent, or was blocked by policy.
invalid_grantCode expired, already used, or PKCE verifier mismatched.
invalid_scopeRequested scope isn't in allowedScopes.
unsupported_response_typeresponse_type was not code.
server_errorInternal — report with request ID.

For full server-side error codes and JSON response shapes see API errors and the API reference.

Picking a library

Any conformant OIDC client library works. Verified-compatible picks:

  • Node / TypeScript (server): openid-client.
  • Browser / SPA: oidc-client-ts.
  • Go: go-oidc.
  • Python: authlib.
  • Rust: openidconnect.

Always configure via discovery URL. Always verify ID token signatures against the JWKS. Never skip nonce validation.

On this page

  • Discovery
  • Register a client
  • The authorization code flow with PKCE
  • 1. Generate PKCE values and anti-forgery state
  • 2. Redirect the user to the authorization endpoint
  • 3. BluAuth handles the user
  • 4. Exchange the code at the token endpoint
  • 5. Validate the ID token
  • 6. Call userinfo (optional)
  • ID token claims
  • Refreshing tokens
  • Logout
  • Revocation and introspection
  • Edge cases
  • Example client (Node + openid-client)
  • Errors
  • Picking a library
DocsPrivacyTerms
© 2026 Blu Digital Group