> ## Documentation Index
> Fetch the complete documentation index at: https://docs.noresponse.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Other OIDC providers

> Assess a new account from any OpenID Connect provider with fromOIDC and overrides. Find the dedicated adapters for Supabase, Firebase, Cognito, Entra ID, Keycloak and Ory.

NoResponse assesses each new account for abuse. It returns the current risk score of the account, from 0 to 100, where a higher score means more risk. It also returns a decision: `allow`, `step_up`, `review` or `block`.

Your auth provider already knows facts that make the score more accurate. Examples are whether the user verified the email and whether the user passed a second factor. `auth_context` is the part of the assessment request that carries these facts. An adapter in each NoResponse SDK builds it from the claims that you verified.

Any provider that issues OpenID Connect ID tokens works with the generic adapter, `fromOIDC`. Examples are Okta, Ping Identity, OneLogin, Zitadel, Authentik and Google. Supabase, Firebase, Amazon Cognito, Microsoft Entra ID, Keycloak and Ory each have a dedicated adapter that reads their own claims. They are [listed below](#dedicated-adapters). [Clerk](https://docs.noresponse.org/integrations/clerk.md), [WorkOS](https://docs.noresponse.org/integrations/workos.md) and [Auth0](https://docs.noresponse.org/integrations/auth0.md) have their own pages.

Your backend does four things:

1. Verifies the ID token: its signature, issuer and audience.
2. Calls the API of your provider: gets the account age.
3. `fromOIDC`: turns the claims and the overrides into `auth_context`.
4. `assessments.create`: NoResponse returns the assessment.

## What fromOIDC reads

`fromOIDC` reads only the standard claims. Their meaning is the same at every provider:

| Claim | `auth_context` field |
| - | - |
| `iss` | `issuer` (required) |
| `sub` | `subject` (required) |
| `aud` | `audience`, the first value, and `audiences`, every value |
| `sid` | `session_id` |
| `jti` | `token_id` |
| `iat`, `exp` | `issued_at`, `expires_at` |
| `auth_time` | `auth_time` |

It never reads `email_verified`, `amr`, `act` or any organization or role claim from the token, because providers fill those claims differently. Pass each fact that you can prove as an override. The adapter sends it as given.

## Where each fact comes from

| Fact | Override | Where an OIDC provider keeps it | Effect on the risk score |
| - | - | - | - |
| Email verified | `email_verified` | The `email_verified` claim, in the ID token or the userinfo response, when you request the `email` scope. | Lowers `auth_email_verified` |
| Phone verified | `phone_verified` | The `phone_number_verified` claim, with the `phone` scope. | Lowers `auth_phone_verified` |
| Recent second factor | `mfa` | `amr` with `mfa` ([RFC 8176](https://www.rfc-editor.org/rfc/rfc8176)) means that the user passed a second factor at `auth_time`. Send `present: true`. Send `methods` with the `amr` values that name the factors, up to 8, such as `mfa` or `otp`. Send `age_seconds`, the seconds since `auth_time`. | Lowers `auth_mfa_recent`, when the age is 24 hours or less |
| Account age | `account_created_at` | No standard claim. Read it from the user API of your provider. For Okta, it is `created` from `GET /api/v1/users/{sub}`, with the `okta.users.read` scope. A user who existed at your provider long before reaching your app is not new. A workforce account is an example. | Raises `auth_new_account`, when under 7 days |
| Session age | `session_created_at` | No standard claim. Read it from the session API of your provider, if it has one. | Raises `auth_new_session`, when under 15 minutes |
| Impersonation | `impersonated` | The `act` claim ([RFC 8693](https://www.rfc-editor.org/rfc/rfc8693)), when your provider issues one while an admin acts as the user. | Raises `auth_impersonated` |
| Organization, tenant, roles, scopes | `organization_id`, `tenant_id`, `roles`, `scopes` | The claims of your provider. | None NoResponse stores it with the assessment. |

When your provider cannot supply a fact, leave it out of `auth_context`. An absent fact is not false, and it does not move the score.

## Install

```bash title="TypeScript" theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
npm install jose @noresponse/node
```

```bash title="Python" theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
pip install "pyjwt[crypto]" requests noresponse
```

```bash title="Go" theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
go get github.com/coreos/go-oidc/v3 github.com/noresponsehq/go/v5
```

Set these variables in the environment of your backend:

* `OIDC_ISSUER`: the issuer URL of your provider, exactly as its discovery document gives it, such as `https://example.okta.com/oauth2/default`.
* `OIDC_CLIENT_ID`: the client ID of your application. It is the audience of the ID token.
* `NORESPONSE_API_KEY`: your NoResponse key, with the `assess` scope. The TypeScript and Python clients read it themselves. The Go client takes it as an argument. The [quickstart](https://docs.noresponse.org/quickstart.md) tells you how to get one.

## Assess the new account

Assess each new account once. Do it on the first authenticated request for a `sub` that your database does not know yet. Do it before you create your own records for the account or give it access. The API answers within 3 seconds.

The ID token is the `id_token` in the token response. Your callback route receives it after the login redirect.

> [!WARNING]
>
> Your callback must compare the `nonce` claim with the nonce that it sent. `openid-client` and Authlib do this for you. The go-oidc `Verify` does not, so compare `tok.Nonce` with the nonce that you stored before the redirect.

Your login library can return the verified claims, as `openid-client`, Authlib and go-oidc do. Then pass those claims to the adapter and skip step 1.

Each sample reads the discovery document on first use, not at startup. So a provider outage fails the assessment of that new account, and your error path handles it. The outage does not stop your server from starting.

The first argument to `fromOIDC` names your provider in `auth_context.provider`. It is a lowercase slug of 2 to 32 letters, digits, dots, underscores or hyphens, such as `okta`. `accountCreatedAt` is the creation time of the user from the user API of your provider, as an RFC 3339 string. Leave it empty when you do not have it.

```ts title="TypeScript" highlight={34} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
import { NoResponse, fromOIDC } from "@noresponse/node";

const issuer = process.env.OIDC_ISSUER!;
const clientId = process.env.OIDC_CLIENT_ID!;
const nr = new NoResponse(); // reads NORESPONSE_API_KEY

// Reads the discovery document on first use; a failure is retried on the next call.
let jwks: ReturnType<typeof createRemoteJWKSet> | undefined;
async function signingKeys() {
  if (!jwks) {
    const res = await fetch(`${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(10_000) });
    if (!res.ok) throw new Error(`OIDC discovery answered HTTP ${res.status}`);
    const discovery = (await res.json()) as { jwks_uri: string };
    jwks = createRemoteJWKSet(new URL(discovery.jwks_uri));
  }
  return jwks;
}

const bool = (value: unknown) => (typeof value === "boolean" ? value : undefined);

// amr holding "mfa" means a second factor at auth_time, so its age is the time since then.
function mfaFrom(claims: JWTPayload) {
  const amr = Array.isArray(claims.amr) ? claims.amr : [];
  if (!amr.includes("mfa") || typeof claims.auth_time !== "number") return undefined;
  return { present: true, methods: ["mfa"], ageSeconds: Math.max(0, Math.floor(Date.now() / 1000) - claims.auth_time) };
}

export async function assessAccountCreation(idToken: string, accountCreatedAt: string | undefined, ip: string) {
  // 1. Verify the ID token.
  const { payload: claims } = await jwtVerify(idToken, await signingKeys(), { issuer, audience: clientId });

  // 2. The adapter maps the standard claims; the overrides carry the facts.
  const authContext = fromOIDC("okta", claims, {
    emailVerified: bool(claims.email_verified),
    phoneVerified: bool(claims.phone_number_verified),
    mfa: mfaFrom(claims),
    impersonated: claims.act ? true : undefined,
    accountCreatedAt,
  });

  // 3. NoResponse assesses the account creation.
  return nr.assessments.create({
    accountId: claims.sub!,
    identifiers: { email: typeof claims.email === "string" ? claims.email : undefined, ip },
    flow: "account_creation",
    authContext,
  });
}
```

```python title="Python" highlight={42} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import os
import time
from typing import Any

import jwt
import requests
from noresponse import Assessment, NoResponse, from_oidc

issuer = os.environ["OIDC_ISSUER"]
client_id = os.environ["OIDC_CLIENT_ID"]
nr = NoResponse()  # reads NORESPONSE_API_KEY
_jwks: jwt.PyJWKClient | None = None


def signing_keys() -> jwt.PyJWKClient:
    """Reads the discovery document on first use; a failure is retried on the next call."""
    global _jwks
    if _jwks is None:
        response = requests.get(f"{issuer.rstrip('/')}/.well-known/openid-configuration", timeout=10)
        response.raise_for_status()
        _jwks = jwt.PyJWKClient(response.json()["jwks_uri"], timeout=10)
    return _jwks


def mfa_from(claims: dict[str, Any]) -> dict[str, Any] | None:
    """amr holding "mfa" means a second factor at auth_time, so its age is the time since then."""
    amr = claims.get("amr")
    auth_time = claims.get("auth_time")
    if not isinstance(amr, list) or "mfa" not in amr or not isinstance(auth_time, int):
        return None
    return {"present": True, "methods": ["mfa"], "age_seconds": max(0, int(time.time()) - auth_time)}


def assess_account_creation(id_token: str, account_created_at: str | None, ip: str) -> Assessment:
    # 1. Verify the ID token.
    key = signing_keys().get_signing_key_from_jwt(id_token).key
    claims = jwt.decode(id_token, key, algorithms=["RS256", "ES256"], issuer=issuer, audience=client_id)

    # 2. The adapter maps the standard claims; the overrides carry the facts.
    email_verified = claims.get("email_verified")
    phone_verified = claims.get("phone_number_verified")
    auth_context = from_oidc("okta", claims, {
        "email_verified": email_verified if isinstance(email_verified, bool) else None,
        "phone_verified": phone_verified if isinstance(phone_verified, bool) else None,
        "mfa": mfa_from(claims),
        "impersonated": True if claims.get("act") else None,
        "account_created_at": account_created_at,
    })

    # 3. NoResponse assesses the account creation.
    email = claims.get("email")
    return nr.assessments.create(
        account_id=claims["sub"],
        identifiers={"email": email, "ip": ip} if isinstance(email, str) else {"ip": ip},
        flow="account_creation",
        auth_context=auth_context,
    )
```

```go title="Go" highlight={91} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
package accounts

import (
	"context"
	"net/http"
	"os"
	"sync"
	"time"

	"github.com/coreos/go-oidc/v3/oidc"
	noresponse "github.com/noresponsehq/go/v5"
	"github.com/noresponsehq/go/v5/models/components"
)

var (
	nr             = noresponse.NewClient(os.Getenv("NORESPONSE_API_KEY"))
	verifierMu     sync.Mutex
	cachedVerifier *oidc.IDTokenVerifier
)

// idTokenVerifier reads the discovery document on first use; a failure is
// retried on the next call. NewProvider caches the signing keys and fetches
// them again with the HTTP client in its context, so that client's timeout
// bounds every key fetch too.
func idTokenVerifier(ctx context.Context) (*oidc.IDTokenVerifier, error) {
	verifierMu.Lock()
	defer verifierMu.Unlock()
	if cachedVerifier == nil {
		ctx := oidc.ClientContext(ctx, &http.Client{Timeout: 10 * time.Second})
		ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
		defer cancel()
		provider, err := oidc.NewProvider(ctx, os.Getenv("OIDC_ISSUER"))
		if err != nil {
			return nil, err
		}
		cachedVerifier = provider.Verifier(&oidc.Config{ClientID: os.Getenv("OIDC_CLIENT_ID")})
	}
	return cachedVerifier, nil
}

// mfaFrom reads a second factor from amr. "mfa" in amr means a second factor
// at auth_time, so its age is the time since then.
func mfaFrom(claims map[string]any) map[string]any {
	amr, _ := claims["amr"].([]any)
	authTime, ok := claims["auth_time"].(float64)
	if !ok {
		return nil
	}
	for _, method := range amr {
		if method == "mfa" {
			return map[string]any{"present": true, "methods": []string{"mfa"}, "age_seconds": max(0, time.Now().Unix()-int64(authTime))}
		}
	}
	return nil
}

// AssessAccountCreation takes accountCreatedAt from your provider's user API as an
// RFC 3339 string, or "" when you do not have it.
func AssessAccountCreation(ctx context.Context, idToken, accountCreatedAt, ip string) (*components.Assessment, error) {
	// 1. Verify the ID token.
	verifier, err := idTokenVerifier(ctx)
	if err != nil {
		return nil, err
	}
	tok, err := verifier.Verify(ctx, idToken)
	if err != nil {
		return nil, err
	}
	var claims map[string]any
	if err := tok.Claims(&claims); err != nil {
		return nil, err
	}

	// 2. The adapter maps the standard claims; the overrides carry the facts.
	overrides := map[string]any{}
	if v, ok := claims["email_verified"].(bool); ok {
		overrides["email_verified"] = v
	}
	if v, ok := claims["phone_number_verified"].(bool); ok {
		overrides["phone_verified"] = v
	}
	if mfa := mfaFrom(claims); mfa != nil {
		overrides["mfa"] = mfa
	}
	if claims["act"] != nil {
		overrides["impersonated"] = true
	}
	if accountCreatedAt != "" {
		overrides["account_created_at"] = accountCreatedAt
	}
	authContext, err := noresponse.FromOIDCClaims("okta", claims, overrides)
	if err != nil {
		return nil, err
	}

	// 3. NoResponse assesses the account creation. The second argument is the
	// Idempotency-Key, which the SDK reuses across its own retries.
	identifiers := components.AssessmentRequestIdentifiers{IP: noresponse.Pointer(ip)}
	if email, ok := claims["email"].(string); ok {
		identifiers.Email = noresponse.Pointer(email)
	}
	res, err := nr.Assessments.Create(ctx, noresponse.NewIdempotencyKey(), components.AssessmentRequest{
		AccountID:   tok.Subject,
		Identifiers: identifiers,
		Flow:        components.FlowAccountCreation,
		AuthContext: authContext,
	})
	if err != nil {
		return nil, err
	}
	return &res.Result, nil
}
```

`ip` is the public address of the client. Behind a load balancer, read it from the header that the load balancer sets, such as the last address that it appended to `X-Forwarded-For`. Never read it from a value that the client controls.

The `sub` of the token is the `account_id`. It is stable, and the API of your provider takes it when you act on the result later.

The flow is account creation, so NoResponse waits up to 3000 ms for its full set of sources. To wait only for the sources that answer within 500 ms, send `depth: "quick"`. The other sources still finish later, and their results reach you through the webhook.

> [!WARNING]
>
> **The adapter sends overrides as given**
>
> The adapter tests the claims that it maps against the limits of the API, and drops a value that does not fit. It does not test overrides. An override replaces the mapped value, `false` included. The adapter skips a `null` or `undefined` override.
>
> Write an override only from a verified token or from a record that your backend read from the provider. The adapter sets `verified: true` and `transport: "backend"` to say that your backend did the verification.

### Override names

Overrides use the `auth_context` field names:

* In TypeScript, camelCase: `emailVerified`, `accountCreatedAt`, `mfa.ageSeconds`.
* In Python and Go, snake\_case keys: `email_verified`, `account_created_at`, `mfa.age_seconds`.

Times are RFC 3339 strings. The [field reference](https://docs.noresponse.org/guides/auth-session.md#field-reference) lists every field.

## Act on the answer

The answer starts with `assessment.account`, the one current risk of the account. Your code acts on `assessment.account.risk.decision`, the decision for the flow that you sent:

* `allow`: let the account in.
* `step_up`: ask for more proof first, such as a second factor.
* `review`: hold the account until your team looks at it.
* `block`: refuse the account. NoResponse answers `block` after you set a block threshold and the score reaches it.
* Any other value: treat it as `review`.

`assessment.account.risk.score` and `assessment.account.risk.reasons` tell you why. `assessment.detail` holds the facts of this assessment, such as its `status`, the sources that did not answer and the summary facts.

* `assessment.account` is absent while the first assessment of the account is still pending. Apply your [fallback](https://docs.noresponse.org/guides/assess.md#choose-your-fallback). Then take the final assessment from the [webhook](https://docs.noresponse.org/guides/webhooks.md) when it arrives.
* If the call fails or times out, apply the same fallback.

[Turn an assessment into a decision](https://docs.noresponse.org/guides/decisions.md) gives a full rule set to copy.

## What the adapter sends

This is the `auth_context` for a new Okta user with a verified email who passed a second factor at login a minute ago:

```json title="auth_context for a new Okta user" theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  "provider": "okta",
  "issuer": "https://example.okta.com/oauth2/default",
  "subject": "00u8k2m4p6r8t0v2x4z6",
  "audience": "0oa1b2c3d4e5f6g7h8i9",
  "audiences": ["0oa1b2c3d4e5f6g7h8i9"],
  "token_id": "ID.Zx7Qm2Np4Rs6Tu8Vw0Xy2Za4Bc6De8F",
  "issued_at": "2026-10-21T16:41:50Z",
  "expires_at": "2026-10-21T17:41:50Z",
  "auth_time": "2026-10-21T16:41:49Z",
  "email_verified": true,
  "mfa": { "present": true, "methods": ["mfa"], "age_seconds": 60 },
  "account_created_at": "2026-10-21T16:41:48Z",
  "verified": true,
  "transport": "backend"
}
```

That context fires three reasons. `account.risk.reasons` lists each one with its weight:

* `auth_new_account` raises the score for any new account.
* `auth_email_verified` and `auth_mfa_recent` lower it. They make this account different from one with nothing verified.

## Dedicated adapters

These adapters read the claims of each provider as well as the standard ones. Every SDK has them. Each adapter takes the verified claims and an optional overrides object, in the same way as `fromOIDC`, but without the slug.

* Each Go function takes the claims and the overrides as `map[string]any`. Pass `nil` for no overrides.
* Times in overrides are RFC 3339 strings, so convert the milliseconds of a provider first.

> **Supabase Auth**
>
> TypeScript `fromSupabase`, Python `from_supabase`, Go `FromSupabaseClaims`.
>
> * **Reads from the claims:** the access token, verified with the JWKS or JWT secret of your project. It reads `session_id` and `role`, a second factor from `aal` and `amr`, and an anonymous user as `assurance_level: low`.
> * **Pass as overrides:** `email_verified` and `phone_verified`, `true` when the user has `email_confirmed_at` or `phone_confirmed_at`. `account_created_at` from the `created_at` of the user.
> * [Walkthrough](https://docs.noresponse.org/guides/auth-session.md#your-auth-provider).

> **Firebase Authentication**
>
> TypeScript `fromFirebase`, Python `from_firebase`, Go `FromFirebaseClaims`.
>
> * **Reads from the claims:** the ID token claims from `verifyIdToken` in the Admin SDK. It reads `firebase.tenant` as `tenant_id`, `firebase.sign_in_second_factor` as `mfa`, and an anonymous login as `assurance_level: low`. Custom claims never change the mapping.
> * **Pass as overrides:** `email_verified` from `emailVerified` on the user record from `getUser`, because custom claims can overwrite the `email_verified` of the token. `phone_verified: true` when the user has a `phoneNumber`. `account_created_at` from `metadata.creationTime`.
> * [Walkthrough](https://docs.noresponse.org/guides/auth-session.md#your-auth-provider).

> **Amazon Cognito**
>
> TypeScript `fromCognito`, Python `from_cognito`, Go `FromCognitoClaims`.
>
> * **Reads from the claims:** a user pool ID token or access token. It reads `client_id` as the audience of an access token, `origin_jti` as `session_id`, `cognito:groups` as `roles`, `scope`, and `amr`. Claims that a pre token generation Lambda adds never change the mapping.
> * **Pass as overrides:** `email_verified` and `phone_verified` from the `email_verified` and `phone_number_verified` of the ID token. Use `AdminGetUser` instead if a pre token generation Lambda can change them. `account_created_at` from `UserCreateDate` in `AdminGetUser`.

> **Microsoft Entra ID**
>
> TypeScript `fromEntra`, Python `from_entra`, Go `FromEntraClaims`.
>
> * **Reads from the claims:** a v1.0 or v2.0 ID or access token. It reads `tid` as `tenant_id`, `uti` as `token_id`, `roles`, `scp`, and `amr` when the token carries it. `mfa` in `amr` sets `mfa.present`.
> * **Pass as overrides:** `account_created_at` from `createdDateTime` on the Microsoft Graph user.

> **Keycloak**
>
> TypeScript `fromKeycloak`, Python `from_keycloak`, Go `FromKeycloakClaims`.
>
> * **Reads from the claims:** an ID or access token. It reads `sid` or `session_state`, `organization`, `realm_access.roles` and `scope`. It reads the `impersonator` claim from the impersonator mappers of Keycloak. It reads `amr` from the AMR mapper, where `mfa` or factors of two kinds set `mfa.present`.
> * **Pass as overrides:** `email_verified` from the `email_verified` of the token. `account_created_at` from `createdTimestamp` on the admin API user. `session_created_at` from `start` on the admin API session. Both are in milliseconds.

> **Ory (Kratos session)**
>
> TypeScript `fromOry`, Python `from_ory`, Go `FromOrySession`.
>
> * **Reads from the session:** the session object from `toSession`, not a JWT. It reads the identity id, the session id and `authenticated_at`. It reads `issued_at` as `session_created_at` and the `created_at` of the identity as `account_created_at`. It reads the second factor from the assurance level and the authentication methods.
> * **Pass as overrides:** `issuer`, required. This is the URL of your Ory project, which the session does not carry. `email_verified` from `verified` on the verifiable address of the identity.

> **Ory OAuth2 (Hydra)**
>
> TypeScript `fromOryOAuth2`, Python `from_ory_oauth2`, Go `FromOryOAuth2Claims`.
>
> * **Reads from the claims:** the ID token. It reads the standard claims, and `amr`, where factors of two kinds set `mfa.present`.
> * **Pass as overrides:** the facts in [the table above](#where-each-fact-comes-from).

> **Any other OIDC provider**
>
> TypeScript `fromOIDC`, Python `from_oidc`, Go `FromOIDCClaims`.
>
> * **Reads from the claims:** the standard claims of the ID token. It takes the provider slug first.
> * **Pass as overrides:** every fact, as this page shows.

## Next steps

> **[Turn an assessment into a decision](https://docs.noresponse.org/guides/decisions.md)**
>
> Rules for `allow`, `step_up`, `review` and `block`.

> **[Pending, retries and idempotency](https://docs.noresponse.org/guides/pending.md)**
>
> What a `pending` answer carries and how retries work.

> **[Pass your auth session](https://docs.noresponse.org/guides/auth-session.md)**
>
> Every `auth_context` field and the six auth reason codes.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.