Pass your auth session
If signup runs through your auth provider, your backend already knows things that matter: whether the email is verified, how old the session is, whether a second factor was used, whether an admin is acting as the user. auth_context carries those facts into the assessment, and six reason codes can then move the score.
Your backend verifies the session with your auth provider’s SDK, reads the user and session records, and sends what it learned. Each NoResponse SDK has an adapter per auth provider that turns verified claims into auth_context. You add the facts that live on the records as overrides.
browser ──▶ your signup route ──▶ verify the session with your auth provider's SDK ──▶ read the user and session records ──▶ build auth_context with the adapter ──▶ POST /v1/assessmentsYour auth provider
Section titled “Your auth provider”Verify the session token with Clerk’s backend SDK and its authorised-party check, then fetch the user and the session with the backend API. Clerk’s session token holds sub, sid, exp, the active organisation in o and the factor ages in fva. The adapter maps those. It does not send iat, because Clerk refreshes the token about every minute, so iat is a refresh clock and not the session’s age.
| Field | Where it comes from |
|---|---|
session_created_at |
Session.createdAt from the backend API. Unix milliseconds, so convert to RFC 3339. The only source of the new-session signal. |
account_created_at |
User.createdAt from the backend API. Unix milliseconds. |
email_verified |
User.primaryEmailAddress.verification.status === "verified". Not read from the token. |
phone_verified |
User.primaryPhoneNumber.verification.status === "verified". Not read from the token. |
mfa |
Mapped from fva[1], the second-factor age in minutes. [7, 1] becomes mfa.age_seconds: 60; [7, -1] sends no mfa. |
impersonated |
Mapped from the act claim. |
import { createClerkClient, verifyToken } from "@clerk/backend";import { fromClerk } from "@noresponse/node";
const clerk = createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY! });const claims = await verifyToken(sessionToken, { secretKey: process.env.CLERK_SECRET_KEY!, authorizedParties: ["https://app.example.com"] });const [user, session] = await Promise.all([clerk.users.getUser(claims.sub), clerk.sessions.getSession(claims.sid)]);
const authContext = fromClerk(claims, { sessionCreatedAt: new Date(session.createdAt).toISOString(), accountCreatedAt: new Date(user.createdAt).toISOString(), emailVerified: user.primaryEmailAddress?.verification?.status === "verified", phoneVerified: user.primaryPhoneNumber?.verification?.status === "verified",});import osfrom datetime import datetime, timezone
from clerk_backend_api import Clerkfrom clerk_backend_api.security.types import AuthenticateRequestOptionsfrom noresponse.auth import from_clerk
def rfc3339(ms: int) -> str: return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
clerk = Clerk(bearer_auth=os.environ["CLERK_SECRET_KEY"])state = clerk.authenticate_request(request, AuthenticateRequestOptions(authorized_parties=["https://app.example.com"]))claims = state.payloaduser = clerk.users.get(user_id=claims["sub"])session = clerk.sessions.get(session_id=claims["sid"])
auth_context = from_clerk(claims, { "session_created_at": rfc3339(session.created_at), "account_created_at": rfc3339(user.created_at), "email_verified": user.primary_email_address.verification.status == "verified", "phone_verified": user.primary_phone_number.verification.status == "verified",})claims, err := jwt.Verify(ctx, &jwt.VerifyParams{Token: sessionToken, AuthorizedPartyHandler: func(azp string) bool { return azp == "https://app.example.com" }})if err != nil { return err}var raw map[string]any // the typed claims have no fva, so decode the verified payload segment into a map_ = json.Unmarshal(payloadSegment(sessionToken), &raw)usr, _ := user.Get(ctx, claims.Subject)sess, _ := session.Get(ctx, claims.SessionID)
authContext, err := noresponse.FromClerkClaims(raw, map[string]any{ "session_created_at": time.UnixMilli(sess.CreatedAt).UTC().Format(time.RFC3339), "account_created_at": time.UnixMilli(usr.CreatedAt).UTC().Format(time.RFC3339), "email_verified": usr.PrimaryEmailAddress != nil && usr.PrimaryEmailAddress.Verification.Status == "verified", "phone_verified": usr.PrimaryPhoneNumber != nil && usr.PrimaryPhoneNumber.Verification.Status == "verified",})Verify the AuthKit access token against the WorkOS JWKS on your backend, then fetch the user and the session whose id matches the token’s sid. The default token carries sub, sid, jti, iss, iat, exp, org_id, role or roles, permissions, and act when an admin impersonates the user. The adapter maps all of those. Contact verification and timestamps live on the records, so you pass them.
| Field | Where it comes from |
|---|---|
email_verified |
User.email_verified. Not read from the token. |
account_created_at |
User.created_at, already RFC 3339. |
session_created_at |
Session.created_at. Do not copy the token’s iat, which the adapter sends as issued_at for correlation only. |
phone_verified |
Leave it out. AuthKit has no phone field. |
impersonated |
Mapped from the act claim. |
mfa |
Leave it out unless your own record proves a recent second factor. |
import { createRemoteJWKSet, jwtVerify } from "jose";import { WorkOS } from "@workos-inc/node";import { fromWorkOS } from "@noresponse/node";
const workos = new WorkOS(process.env.WORKOS_API_KEY!);const jwks = createRemoteJWKSet(new URL(workos.userManagement.getJwksUrl(process.env.WORKOS_CLIENT_ID!)));const { payload: claims } = await jwtVerify(accessToken, jwks, { issuer: "https://api.workos.com" });const user = await workos.userManagement.getUser(claims.sub!);const session = await workos.userManagement.getSession(claims.sid as string);
const authContext = fromWorkOS(claims, { emailVerified: user.emailVerified, accountCreatedAt: user.createdAt, sessionCreatedAt: session.createdAt,});import os
import jwtfrom workos import WorkOSClientfrom noresponse.auth import from_workos
workos = WorkOSClient(api_key=os.environ["WORKOS_API_KEY"], client_id=os.environ["WORKOS_CLIENT_ID"])jwks = jwt.PyJWKClient(workos.user_management.get_jwks_url())claims = jwt.decode(access_token, jwks.get_signing_key_from_jwt(access_token).key, algorithms=["RS256"], issuer="https://api.workos.com")user = workos.user_management.get_user(claims["sub"])session = workos.user_management.get_session(claims["sid"])
auth_context = from_workos(claims, { "email_verified": user.email_verified, "account_created_at": user.created_at, "session_created_at": session.created_at,})var claims jwt.MapClaims // github.com/golang-jwt/jwt/v5 with a keyfunc over the WorkOS JWKSif _, err := jwt.ParseWithClaims(accessToken, &claims, jwks.Keyfunc, jwt.WithValidMethods([]string{"RS256"}), jwt.WithIssuer("https://api.workos.com"), jwt.WithExpirationRequired()); err != nil { return err}usr, _ := workos.UserManagement.GetUser(ctx, usermanagement.GetUserOpts{User: claims["sub"].(string)})sess, _ := workos.UserManagement.GetSession(ctx, claims["sid"].(string))
authContext, err := noresponse.FromWorkOSClaims(claims, map[string]any{ "email_verified": usr.EmailVerified, "account_created_at": usr.CreatedAt, // the records carry RFC 3339 strings "session_created_at": sess.CreatedAt,})Verify the ID token against your tenant’s JWKS, with the issuer and your client id as audience. The adapter reads email_verified, phone_number_verified, amr, auth_time, org_id, scope or permissions and act from the verified claims. The ID token holds only what your Auth0 rules and Actions put in it, so check yours. Account and session creation times come from the Management API, with a machine-to-machine token that has read:users and read:sessions.
Pass the Auth0 user id as account_id unchanged. auth0|64abc0de1234567890abcdef is a valid account id, because the vertical bar is accepted.
| Field | Where it comes from |
|---|---|
email_verified |
The email_verified claim. An override from the Management API user record replaces it. |
phone_verified |
The OIDC phone_number_verified claim. The Management API calls the same fact phone_verified. Leave it out when the user has no phone. |
account_created_at |
created_at on the Management API user, GET /api/v2/users/{id}. |
session_created_at |
created_at on the Management API session, GET /api/v2/sessions/{sid}. Leave it out if your tenant has no sessions endpoint, and never substitute auth_time. |
mfa |
Mapped from amr. An amr that holds mfa sets mfa.present but no age, so it does not score. Pass age_seconds only when you know the real factor age. |
impersonated |
Mapped from the act claim. |
import { createRemoteJWKSet, jwtVerify } from "jose";import { ManagementClient } from "auth0";import { fromAuth0 } from "@noresponse/node";
const issuer = `https://${process.env.AUTH0_DOMAIN}/`;const jwks = createRemoteJWKSet(new URL(`${issuer}.well-known/jwks.json`));const { payload: claims } = await jwtVerify(idToken, jwks, { issuer, audience: process.env.AUTH0_CLIENT_ID! });const management = new ManagementClient({ domain: process.env.AUTH0_DOMAIN!, clientId: process.env.AUTH0_M2M_CLIENT_ID!, clientSecret: process.env.AUTH0_M2M_CLIENT_SECRET! });const { data: user } = await management.users.get({ id: claims.sub! });
const authContext = fromAuth0(claims, { accountCreatedAt: user.created_at,});import os
import jwtfrom auth0.management import Auth0from noresponse.auth import from_auth0
issuer = f"https://{os.environ['AUTH0_DOMAIN']}/"jwks = jwt.PyJWKClient(f"{issuer}.well-known/jwks.json")claims = jwt.decode(id_token, jwks.get_signing_key_from_jwt(id_token).key, algorithms=["RS256"], issuer=issuer, audience=os.environ["AUTH0_CLIENT_ID"])user = Auth0(os.environ["AUTH0_DOMAIN"], management_token).users.get(claims["sub"])
auth_context = from_auth0(claims, { "account_created_at": user["created_at"],})var claims jwt.MapClaims // github.com/golang-jwt/jwt/v5 with a keyfunc over the tenant JWKSif _, err := jwt.ParseWithClaims(idToken, &claims, jwks.Keyfunc, jwt.WithValidMethods([]string{"RS256"}), jwt.WithIssuer(issuer), jwt.WithAudience(clientID), jwt.WithExpirationRequired()); err != nil { return err}usr, _ := management.User.Read(ctx, claims["sub"].(string))
authContext, err := noresponse.FromAuth0Claims(claims, map[string]any{ "account_created_at": usr.GetCreatedAt().UTC().Format(time.RFC3339),})Verify the ID token with your OIDC library, then read the facts the token lacks. The generic adapter maps only the standard claims: iss, sub, aud, sid, jti, iat, exp and auth_time. It does not guess organisation, role, MFA, contact verification or impersonation semantics, so pass each fact you can prove as an override. The first argument is a lowercase slug of 2 to 32 characters for your auth provider, such as keycloak or okta.
| Field | Where it comes from |
|---|---|
email_verified |
email_verified from the userinfo endpoint. Test for true; an email being present proves nothing. |
phone_verified |
phone_number_verified from userinfo. |
account_created_at |
The user record in your auth provider’s admin API. In Keycloak, createdTimestamp on the user, in milliseconds. |
session_created_at |
The session record. In Keycloak, start on the session from the admin sessions endpoint, in milliseconds. |
impersonated |
The act claim or the auth provider’s impersonation flag. The generic adapter does not read act, so set it yourself. |
mfa |
Your auth provider’s amr, acr or step-up record. Send { present: true, age_seconds } only with a real factor age. |
import { createRemoteJWKSet, jwtVerify } from "jose";import { fromOIDC } from "@noresponse/node";
const issuer = "https://id.example.com/realms/main";const jwks = createRemoteJWKSet(new URL(`${issuer}/protocol/openid-connect/certs`));const { payload: claims } = await jwtVerify(idToken, jwks, { issuer, audience: "app" });const userinfo = await fetch(`${issuer}/protocol/openid-connect/userinfo`, { headers: { authorization: `Bearer ${accessToken}` } }).then((r) => r.json() as Promise<{ email_verified?: boolean }>);const account = await adminApi.getUser(claims.sub!);
const authContext = fromOIDC("keycloak", claims, { emailVerified: userinfo.email_verified === true, accountCreatedAt: new Date(account.createdTimestamp).toISOString(), impersonated: typeof claims.act === "object" && claims.act !== null,});from datetime import datetime, timezone
import jwtimport requestsfrom noresponse.auth import from_oidc
issuer = "https://id.example.com/realms/main"jwks = jwt.PyJWKClient(f"{issuer}/protocol/openid-connect/certs")claims = jwt.decode(id_token, jwks.get_signing_key_from_jwt(id_token).key, algorithms=["RS256"], issuer=issuer, audience="app")userinfo = requests.get(f"{issuer}/protocol/openid-connect/userinfo", headers={"Authorization": f"Bearer {access_token}"}, timeout=5).json()account = admin_api.get_user(claims["sub"])created_at = datetime.fromtimestamp(account["createdTimestamp"] / 1000, tz=timezone.utc)
auth_context = from_oidc("keycloak", claims, { "email_verified": userinfo.get("email_verified") is True, "account_created_at": created_at.strftime("%Y-%m-%dT%H:%M:%SZ"), "impersonated": isinstance(claims.get("act"), dict),})provider, err := oidc.NewProvider(ctx, issuer) // github.com/coreos/go-oidc/v3if err != nil { return err}idTok, err := provider.Verifier(&oidc.Config{ClientID: "app"}).Verify(ctx, rawIDToken)if err != nil { return err}var claims map[string]any_ = idTok.Claims(&claims)info, _ := provider.UserInfo(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: accessToken}))account, _ := adminAPI.GetUser(ctx, idTok.Subject)_, impersonated := claims["act"].(map[string]any)
authContext, err := noresponse.FromOIDCClaims("keycloak", claims, map[string]any{ "email_verified": info.EmailVerified, "account_created_at": time.UnixMilli(account.CreatedTimestamp).UTC().Format(time.RFC3339), "impersonated": impersonated,})Verify the access token with the project’s JWKS (or the legacy HS256 secret) on your backend, then read the user with supabase.auth.getUser(). The token carries sub, session_id, aal, amr, iat, exp and is_anonymous, and the adapter maps those. Contact verification and the account’s age live on the user record, so you pass them.
| Field | Where it comes from |
|---|---|
account_created_at |
user.created_at, already RFC 3339. |
email_verified |
user.email_confirmed_at is set. Not read from the token. |
phone_verified |
user.phone_confirmed_at is set. Not read from the token. |
mfa |
Mapped from aal and amr. The adapter has no clock, so add age_seconds from the timestamp of the newest second-factor entry in amr if you want the factor’s age to count. |
import { fromSupabase } from "@noresponse/node";
type SupabaseUser = { created_at: string; email_confirmed_at?: string | null; phone_confirmed_at?: string | null };
export function supabaseContext(claims: Record<string, unknown>, user: SupabaseUser) { return fromSupabase(claims, { accountCreatedAt: user.created_at, emailVerified: Boolean(user.email_confirmed_at), phoneVerified: Boolean(user.phone_confirmed_at), });}from typing import Any
from noresponse import AuthContext, from_supabase
def supabase_context(claims: dict[str, Any], user: dict[str, Any]) -> AuthContext: return from_supabase(claims, { "account_created_at": user["created_at"], "email_verified": bool(user.get("email_confirmed_at")), "phone_verified": bool(user.get("phone_confirmed_at")), })package auth
import ( noresponse "github.com/noresponsehq/go/v2" "github.com/noresponsehq/go/v2/models/components")
type SupabaseUser struct { CreatedAt string EmailConfirmedAt string PhoneConfirmedAt string}
func SupabaseContext(claims map[string]any, user SupabaseUser) (*components.AuthContext, error) { return noresponse.FromSupabaseClaims(claims, map[string]any{ "account_created_at": user.CreatedAt, "email_verified": user.EmailConfirmedAt != "", "phone_verified": user.PhoneConfirmedAt != "", })}Verify the ID token with the Admin SDK’s verifyIdToken(idToken, true), then read the account with getUser(uid). Contact verification comes from the user record and never from top-level token claims, because your own custom claims share that namespace and never change the mapping. A second factor shows as firebase.sign_in_second_factor.
| Field | Where it comes from |
|---|---|
account_created_at |
user.metadata.creationTime, converted to RFC 3339. |
email_verified |
user.emailVerified from the user record. |
phone_verified |
Set it when user.phoneNumber is present. |
mfa |
Mapped from firebase.sign_in_second_factor. Firebase gives no factor age, so the age of auth_time is the age of the factor if you add it. |
import { fromFirebase } from "@noresponse/node";
type FirebaseUser = { emailVerified: boolean; phoneNumber?: string; metadata: { creationTime: string } };
export function firebaseContext(claims: Record<string, unknown>, user: FirebaseUser) { return fromFirebase(claims, { accountCreatedAt: new Date(user.metadata.creationTime).toISOString().replace(/\.\d+Z$/, "Z"), emailVerified: user.emailVerified, phoneVerified: user.phoneNumber ? true : undefined, });}from datetime import datetimefrom typing import Any
from noresponse import AuthContext, from_firebase
def firebase_context(claims: dict[str, Any], created: datetime, email_verified: bool, phone_number: str | None) -> AuthContext: return from_firebase(claims, { "account_created_at": created.strftime("%Y-%m-%dT%H:%M:%SZ"), "email_verified": email_verified, "phone_verified": True if phone_number else None, })package auth
import ( "time"
noresponse "github.com/noresponsehq/go/v2" "github.com/noresponsehq/go/v2/models/components")
func FirebaseContext(claims map[string]any, created time.Time, emailVerified bool, phoneNumber string) (*components.AuthContext, error) { overrides := map[string]any{ "account_created_at": created.UTC().Format(time.RFC3339), "email_verified": emailVerified, } if phoneNumber != "" { overrides["phone_verified"] = true } return noresponse.FromFirebaseClaims(claims, overrides)}Send it with the assessment
Section titled “Send it with the assessment”Pass the adapter’s result as auth_context. The body below is what the Clerk adapter produces for Jordan Lee’s signup.
const assessment = await nr.assessments.create( { accountId: "usr_8f2k1", policyContext: { flow: "signup", region: "GB" }, authContext, }, { idempotencyKey: `signup-${signupAttempt.id}` },);assessment = client.assessments.create( account_id="usr_8f2k1", policy_context={"flow": "signup", "region": "GB"}, auth_context=auth_context, idempotency_key=f"signup-{signup_attempt.id}",)res, err := client.Assessments.Create(ctx, "signup-"+signupAttempt.ID, components.AssessmentRequest{ AccountID: "usr_8f2k1", Identifiers: components.AssessmentRequestIdentifiers{ Name: noresponse.Pointer("Jordan Lee"), IP: noresponse.Pointer("81.2.69.160"), }, PolicyContext: &components.AssessmentRequestPolicyContext{ Flow: noresponse.Pointer(components.FlowSignup), Region: noresponse.Pointer("GB"), }, AuthContext: authContext,})curl https://api.noresponse.org/v1/assessments \ -H "Authorization: Bearer $NORESPONSE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: signup-7c1f0a2b-3d4e-4f5a-9b6c-1d2e3f4a5b6c" \ -d '{ "account_id": "usr_8f2k1", "identifiers": { "email": "[email protected]", "name": "Jordan Lee", "ip": "81.2.69.160" }, "policy_context": { "flow": "signup", "region": "GB" }, "auth_context": { "provider": "clerk", "issuer": "https://clerk.example.com", "subject": "user_2nXk1QmP5rT8vW3yZ6aB9cD0eF4", "session_id": "sess_2nXk3HjL7pR1sT5uV9wX2yZ4aB6", "expires_at": "2026-10-21T16:43:36Z", "organization_id": "org_2nXk5MnQ8sU2vW6xY0zA3bC7dE1", "roles": ["member"], "mfa": { "present": true, "age_seconds": 60 }, "impersonated": false, "session_created_at": "2026-10-21T16:41:50Z", "account_created_at": "2026-10-21T16:41:48Z", "email_verified": true, "phone_verified": false, "verified": true, "transport": "backend" } }'The auth facts and their effect
Section titled “The auth facts and their effect”Six facts can move the score. Each one needs the field in the right column. A fact you leave out is absent, not false, and does not move the score. The reason appears in risk.reasons with its weight when it fires.
| Fact | Reason code | Effect | Fires when |
|---|---|---|---|
| Email verified | auth_email_verified |
Lowers the score | email_verified is true. |
| Phone verified | auth_phone_verified |
Lowers the score | phone_verified is true. |
| Recent second factor | auth_mfa_recent |
Lowers the score | mfa.present is true and mfa.age_seconds is 86,400 or less. |
| Impersonated | auth_impersonated |
Raises the score | impersonated is true. |
| New session | auth_new_session |
Raises the score | session_created_at is within 15 minutes of the server clock. |
| New account | auth_new_account |
Raises the score | account_created_at is within 7 days of the server clock. |
Jordan Lee’s signup above fires auth_email_verified, auth_mfa_recent, auth_new_session and auth_new_account. That is normal for a fresh signup. The weights appear in risk.reasons, so you can see what each fact contributed.
provider, organization_id, tenant_id, roles and scopes are kept for correlation and do not move the score. Your auth provider’s own bot and fraud controls keep working beside NoResponse.
Field reference
Section titled “Field reference”provider, issuer, subject and verified are required. Everything else is optional. All times are RFC 3339 strings, and the adapters write a numeric claim as whole UTC seconds, such as 2026-10-21T16:41:50Z.
| Field | Meaning |
|---|---|
provider |
clerk, workos, auth0, or a lowercase slug of 2 to 32 characters for any other auth provider. |
issuer |
The verified iss, an HTTPS URL of up to 256 characters. |
subject |
The verified, stable sub. Never an email. |
audience, audiences |
The verified audience your API checked. audiences holds up to 8 values when the token has several. |
session_id, token_id |
The verified sid and jti, when your auth provider issues them. |
issued_at, expires_at |
The token’s iat and exp. They describe the token, not the session’s age. |
session_created_at |
When the session started, from the session record. |
auth_time |
When the person last authenticated. Not a substitute for session_created_at. |
account_created_at |
When the user record was created. |
email_verified, phone_verified |
From a verified ID token, userinfo response or user record. |
mfa |
present, up to 8 methods and age_seconds for the current session. |
impersonated |
true when an admin acts as the user. |
organization_id, tenant_id |
The active organisation or workspace, and the auth provider’s tenant when it differs. |
roles, scopes |
Up to 32 values each, at most 64 characters each. The adapters drop longer values. |
assurance_level |
unknown, low, medium or high, if you map one. |
verified |
true once your backend has checked the signature, issuer, audience and expiry. |
transport |
backend or browser. The adapters set backend. |