> ## 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.

# Clerk

> Assess a new Clerk account with the facts that Clerk verified, through the Clerk backend SDK and the fromClerk adapter.

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`.

Clerk already knows facts that make the score more accurate. Examples are whether the user verified the email and when the user last passed a second factor. `auth_context` is the part of the assessment request that carries these facts. `fromClerk` is the Clerk adapter in each NoResponse SDK. It builds `auth_context` from the session claims that Clerk verified, and you add the facts from the Clerk user and session records.

Your backend makes five calls:

1. `verifyToken`: Clerk verifies the session token.
2. `users.getUser`: gets the account age and the verified email and phone.
3. `sessions.getSession`: gets the session age.
4. `fromClerk`: turns the claims and the records into `auth_context`.
5. `assessments.create`: NoResponse returns the assessment.

## What Clerk can supply

| Fact | `auth_context` field | Where Clerk keeps it | Effect on the risk score |
| - | - | - | - |
| Email verified | `email_verified` | The primary email address on the user record: `verification.status` is `verified`. The session token does not hold it. | Lowers `auth_email_verified` |
| Phone verified | `phone_verified` | The primary phone number on the user record, in the same way. | Lowers `auth_phone_verified` |
| Recent second factor | `mfa` | The `fva` claim in the session token. The adapter reads `fva[1]`, the minutes since the last second factor, and sends `mfa.age_seconds`. `-1` means no second factor, so the adapter sends no `mfa`. | Lowers `auth_mfa_recent`, when 24 hours old or less |
| Account age | `account_created_at` | `createdAt` on the user record, in Unix milliseconds. A user that you imported into Clerk long before they reached your app is not new. | Raises `auth_new_account`, when under 7 days |
| Session age | `session_created_at` | `createdAt` on the session record, in Unix milliseconds. | Raises `auth_new_session`, when under 15 minutes |
| Impersonation | `impersonated` | The `act` claim. Clerk sets it while someone from your team impersonates the user. | Raises `auth_impersonated` |
| Organization, role, permissions | `organization_id`, `roles`, `scopes` | The `o` claim (`o.id`, `o.rol`, `o.per`). In the older version 1 session tokens of Clerk, `org_id`, `org_role` and `org_permissions`. | None NoResponse stores it with the assessment. |
| Session and token | `session_id`, `expires_at` | The `sid` and `exp` claims. | None NoResponse stores it with the assessment. |

## What Clerk cannot supply

* **Which second factor the user passed.** `fva` holds only ages, so `mfa.methods` stays empty. Only the age changes the score.
* **The time of the last login.** The Clerk session token has no `auth_time`, so `auth_time` stays empty. `session_created_at` carries the age of the session instead.
* **The issue time of the token.** A Clerk session token lasts 60 seconds, and Clerk issues a new one before it expires. So its `iat` is a refresh clock and not the age of the session. The adapter does not send it.
* **An assurance level.** Clerk has no equivalent of `assurance_level`.

## Install

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

```bash title="Python" theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
pip install clerk-backend-api noresponse
```

```bash title="Go" theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
go get github.com/clerk/clerk-sdk-go/v2 github.com/noresponsehq/go/v5
```

The samples use `@clerk/backend` 3, `clerk-backend-api` 7 and `clerk-sdk-go` 2.

Set two variables in the environment of your backend:

* `CLERK_SECRET_KEY`, from the API keys page of the Clerk Dashboard.
* `NORESPONSE_API_KEY`, a NoResponse key with the `assess` scope.

The TypeScript and Python NoResponse clients read `NORESPONSE_API_KEY` themselves. The Go client takes it as an argument. The [quickstart](https://docs.noresponse.org/quickstart.md) tells you how to get a key.

## Assess the new account

Assess each new account once. Do it on the first authenticated request for a Clerk user 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 session token comes from one of two places:

* On a same-origin request, the `__session` cookie.
* On a cross-origin request, the `Authorization: Bearer` token that your frontend gets from the Clerk `getToken()`.

Replace `https://app.example.com` with the origins of your app, so that the code refuses a token made for another site. Clerk middleware can verify the session token first. Then pass its claims to the adapter and skip step 1. In `@clerk/express` the claims are `getAuth(req).sessionClaims`, and in Next.js they are `(await auth()).sessionClaims`.

```ts title="TypeScript" highlight={14} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { createClerkClient, verifyToken } from "@clerk/backend";
import { NoResponse, fromClerk } from "@noresponse/node";

const clerk = createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY });
const nr = new NoResponse(); // reads NORESPONSE_API_KEY

// Clerk's Backend API gives times in Unix milliseconds.
const rfc3339 = (ms: number) => new Date(ms).toISOString().replace(/\.\d+Z$/, "Z");

export async function assessAccountCreation(sessionToken: string, ip: string) {
  // 1. Clerk verifies the session token and returns its claims.
  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),
  ]);

  // 2. The adapter maps the claims; the records supply the rest.
  const authContext = fromClerk(claims, {
    sessionCreatedAt: rfc3339(session.createdAt),
    accountCreatedAt: rfc3339(user.createdAt),
    emailVerified: user.primaryEmailAddress?.verification?.status === "verified",
    phoneVerified: user.primaryPhoneNumber?.verification?.status === "verified",
  });

  // 3. NoResponse assesses the account creation.
  return nr.assessments.create({
    accountId: user.id,
    identifiers: { email: user.primaryEmailAddress?.emailAddress, ip },
    flow: "account_creation",
    authContext,
  });
}
```

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

from clerk_backend_api import Clerk
from clerk_backend_api.security import verify_token
from clerk_backend_api.security.types import VerifyTokenOptions
from noresponse import Assessment, NoResponse, from_clerk

clerk = Clerk(bearer_auth=os.environ["CLERK_SECRET_KEY"])
nr = NoResponse()  # reads NORESPONSE_API_KEY


def rfc3339(ms: int) -> str:
    """Clerk's Backend API gives times in Unix milliseconds."""
    return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def primary_verified(items: list[Any], primary_id: str | None) -> bool:
    """True when the primary email address or phone number is verified."""
    return any(item.id == primary_id and item.verification is not None and item.verification.status == "verified" for item in items)


def assess_account_creation(session_token: str, ip: str) -> Assessment:
    # 1. Clerk verifies the session token and returns its claims.
    claims = verify_token(session_token, VerifyTokenOptions(
        secret_key=os.environ["CLERK_SECRET_KEY"],
        authorized_parties=["https://app.example.com"],
    ))
    user = clerk.users.get(user_id=claims["sub"])
    session = clerk.sessions.get(session_id=claims["sid"])

    # 2. The adapter maps the claims; the records supply the rest.
    auth_context = from_clerk(claims, {
        "session_created_at": rfc3339(session.created_at),
        "account_created_at": rfc3339(user.created_at),
        "email_verified": primary_verified(user.email_addresses, user.primary_email_address_id),
        "phone_verified": primary_verified(user.phone_numbers, user.primary_phone_number_id),
    })

    # 3. NoResponse assesses the account creation.
    email = next((item.email_address for item in user.email_addresses if item.id == user.primary_email_address_id), None)
    return nr.assessments.create(
        account_id=user.id,
        identifiers={"email": email, "ip": ip} if email else {"ip": ip},
        flow="account_creation",
        auth_context=auth_context,
    )
```

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

import (
	"context"
	"os"
	"sync"
	"time"

	"github.com/clerk/clerk-sdk-go/v2"
	"github.com/clerk/clerk-sdk-go/v2/jwt"
	"github.com/clerk/clerk-sdk-go/v2/session"
	"github.com/clerk/clerk-sdk-go/v2/user"
	noresponse "github.com/noresponsehq/go/v5"
	"github.com/noresponsehq/go/v5/models/components"
)

var (
	nr   = noresponse.NewClient(os.Getenv("NORESPONSE_API_KEY"))
	keys sync.Map // Clerk signing keys by key id, so each is fetched once
)

func init() { clerk.SetKey(os.Getenv("CLERK_SECRET_KEY")) }

// signingKey returns the Clerk key that signed the token.
func signingKey(ctx context.Context, token string) (*clerk.JSONWebKey, error) {
	unverified, err := jwt.Decode(ctx, &jwt.DecodeParams{Token: token})
	if err != nil {
		return nil, err
	}
	if k, ok := keys.Load(unverified.KeyID); ok {
		return k.(*clerk.JSONWebKey), nil
	}
	k, err := jwt.GetJSONWebKey(ctx, &jwt.GetJSONWebKeyParams{KeyID: unverified.KeyID})
	if err != nil {
		return nil, err
	}
	keys.Store(unverified.KeyID, k)
	return k, nil
}

// rfc3339 formats a Clerk Backend API time, which is in Unix milliseconds.
func rfc3339(ms int64) string { return time.UnixMilli(ms).UTC().Format(time.RFC3339) }

// primaryEmail returns the user's primary email address and whether it is verified.
func primaryEmail(u *clerk.User) (*string, bool) {
	for _, e := range u.EmailAddresses {
		if u.PrimaryEmailAddressID != nil && e.ID == *u.PrimaryEmailAddressID {
			return &e.EmailAddress, e.Verification != nil && e.Verification.Status == "verified"
		}
	}
	return nil, false
}

// primaryPhoneVerified reports whether the user's primary phone number is verified.
func primaryPhoneVerified(u *clerk.User) bool {
	for _, p := range u.PhoneNumbers {
		if u.PrimaryPhoneNumberID != nil && p.ID == *u.PrimaryPhoneNumberID {
			return p.Verification != nil && p.Verification.Status == "verified"
		}
	}
	return false
}

func AssessAccountCreation(ctx context.Context, sessionToken, ip string) (*components.Assessment, error) {
	// 1. Clerk verifies the session token and returns its claims.
	jwk, err := signingKey(ctx, sessionToken)
	if err != nil {
		return nil, err
	}
	claims, err := jwt.Verify(ctx, &jwt.VerifyParams{
		Token:                  sessionToken,
		JWK:                    jwk,
		AuthorizedPartyHandler: func(azp string) bool { return azp == "https://app.example.com" },
		// Also decode every claim into a map for the adapter. Clerk's typed
		// claims report a missing fva as 0, which reads as a second factor
		// passed just now.
		CustomClaimsConstructor: func(context.Context) any { return &map[string]any{} },
	})
	if err != nil {
		return nil, err
	}
	raw := *claims.Custom.(*map[string]any)
	usr, err := user.Get(ctx, claims.Subject)
	if err != nil {
		return nil, err
	}
	sess, err := session.Get(ctx, claims.SessionID)
	if err != nil {
		return nil, err
	}

	// 2. The adapter maps the claims; the records supply the rest.
	email, emailVerified := primaryEmail(usr)
	authContext, err := noresponse.FromClerkClaims(raw, map[string]any{
		"session_created_at": rfc3339(sess.CreatedAt),
		"account_created_at": rfc3339(usr.CreatedAt),
		"email_verified":     emailVerified,
		"phone_verified":     primaryPhoneVerified(usr),
	})
	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.
	res, err := nr.Assessments.Create(ctx, noresponse.NewIdempotencyKey(), components.AssessmentRequest{
		AccountID:   usr.ID,
		Identifiers: components.AssessmentRequestIdentifiers{Email: email, IP: noresponse.Pointer(ip)},
		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 Clerk user id is the `account_id`. It is stable, and the Clerk API 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]
>
> **Verified claims, never the token**
>
> The adapter takes the claims that the Clerk SDK returned after it verified the token. It never takes the token string. It sets `verified: true` and `transport: "backend"` to say that your backend did the verification. The API refuses an `auth_context` that holds a token.

## 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 user who created an account under a minute ago. The user has a verified email and passed a second factor during account creation.

```json title="auth_context for a new user" theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  "provider": "clerk",
  "issuer": "https://clerk.example.com",
  "subject": "user_2nXk1QmP5rT8vW3yZ6aB9cD0eF4",
  "session_id": "sess_2nXk3HjL7pR1sT5uV9wX2yZ4aB6",
  "expires_at": "2026-10-21T16:43:36Z",
  "mfa": { "present": true, "age_seconds": 50 },
  "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"
}
```

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

* `auth_new_session` and `auth_new_account` raise 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.

## 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.