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

# WorkOS

> Assess a new WorkOS AuthKit account with the facts that WorkOS verified, through the WorkOS SDK and the fromWorkOS 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`.

WorkOS already knows facts that make the score more accurate. Examples are whether the user verified the email and the ages of the account and the session. `auth_context` is the part of the assessment request that carries these facts. `fromWorkOS` is the WorkOS adapter in each NoResponse SDK. It builds `auth_context` from the AuthKit access token claims that you verified, and you add the facts from the WorkOS user and session records.

Your backend does five things:

1. Verifies the access token: the signature against the JWKS of your client.
2. `getUser`: gets the account age and the verified email.
3. Lists the sessions of the user: gets the session age.
4. `fromWorkOS`: turns the claims and the records into `auth_context`.
5. `assessments.create`: NoResponse returns the assessment.

## What WorkOS can supply

| Fact | `auth_context` field | Where WorkOS keeps it | Effect on the risk score |
| - | - | - | - |
| Email verified | `email_verified` | `emailVerified` on the user record. The access token does not hold it. | Lowers `auth_email_verified` |
| Account age | `account_created_at` | `createdAt` on the user record. A user that SSO or directory sync provisioned 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 whose id is the `sid` of the token, from the session list of the user. | Raises `auth_new_session`, when under 15 minutes |
| Impersonation | `impersonated` | The `act` claim. WorkOS sets it while someone from your team impersonates the user from the WorkOS Dashboard. | Raises `auth_impersonated` |
| Organization, role, permissions | `organization_id`, `roles`, `scopes` | The `org_id`, `role` or `roles`, and `permissions` claims. | None NoResponse stores it with the assessment. |
| Session and token | `session_id`, `token_id`, `issued_at`, `expires_at` | The `sid`, `jti`, `iat` and `exp` claims. | None NoResponse stores it with the assessment. |

## What WorkOS cannot supply

* **A verified phone number.** AuthKit users have no phone number, so `phone_verified` stays empty.
* **A recent second factor.** The access token and the session record do not say whether the user passed MFA, or when. Leave `mfa` out unless a record of your own proves it.
* **The time of the last login.** The access token has no `auth_time`. Its `iat` changes on every refresh, so it is not the age of the session either. `session_created_at` carries that age.
* **An assurance level.** WorkOS has no equivalent of `assurance_level`.

## Install

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

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

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

The samples use `@workos-inc/node` 11, `workos` 10 for Python and `workos-go` 4.

Set three variables in the environment of your backend:

* `WORKOS_API_KEY` and `WORKOS_CLIENT_ID`, from the API keys page of the WorkOS 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 WorkOS 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 access token is the `accessToken` that AuthKit returns:

* `authenticateWithCode` returns it at login.
* `loadSealedSession(...).authenticate()` returns it from the session cookie.
* `withAuth()` returns it in Next.js.

The samples verify the token in the same way as the WorkOS SDKs. They verify the signature against the JWKS that WorkOS publishes for your client ID, and they test the expiry. They do not test the audience or the issuer. WorkOS publishes these signing keys for your client ID only, so a valid signature already ties the token to your application.

```ts title="TypeScript" theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { createRemoteJWKSet, jwtVerify } from "jose";
import { WorkOS } from "@workos-inc/node";
import { NoResponse, fromWorkOS } from "@noresponse/node";

const clientId = process.env.WORKOS_CLIENT_ID!;
const workos = new WorkOS(process.env.WORKOS_API_KEY, { clientId });
const jwks = createRemoteJWKSet(new URL(workos.userManagement.getJwksUrl(clientId)));
const nr = new NoResponse(); // reads NORESPONSE_API_KEY

export async function assessAccountCreation(accessToken: string, ip: string) {
  // 1. Verify the access token, then read the user and the token's session.
  const { payload: claims } = await jwtVerify(accessToken, jwks);
  const user = await workos.userManagement.getUser(claims.sub!);
  const sessions: { id: string; createdAt: string }[] = await (await workos.userManagement.listSessions(user.id)).autoPagination();
  const session = sessions.find((s) => s.id === claims.sid);

  // 2. The adapter maps the claims; the records supply the rest.
  const authContext = fromWorkOS(claims, {
    emailVerified: user.emailVerified,
    accountCreatedAt: user.createdAt,
    sessionCreatedAt: session?.createdAt,
  });

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

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

import jwt
from workos import WorkOSClient
from noresponse import Assessment, NoResponse, 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())
nr = NoResponse()  # reads NORESPONSE_API_KEY


def rfc3339(value: datetime) -> str:
    """The WorkOS Python SDK returns times as datetime."""
    return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def assess_account_creation(access_token: str, ip: str) -> Assessment:
    # 1. Verify the access token, then read the user and the token's session.
    key = jwks.get_signing_key_from_jwt(access_token).key
    claims = jwt.decode(access_token, key, algorithms=["RS256"], options={"verify_aud": False, "require": ["sub", "sid", "exp"]})
    user = workos.user_management.get_user(claims["sub"])
    session = next((s for s in workos.user_management.list_sessions(user.id).auto_paging_iter() if s.id == claims["sid"]), None)

    # 2. The adapter maps the claims; the records supply the rest.
    auth_context = from_workos(claims, {
        "email_verified": user.email_verified,
        "account_created_at": rfc3339(user.created_at),
        "session_created_at": rfc3339(session.created_at) if session else None,
    })

    # 3. NoResponse assesses the account creation.
    return nr.assessments.create(
        account_id=user.id,
        identifiers={"email": user.email, "ip": ip},
        flow="account_creation",
        auth_context=auth_context,
    )
```

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

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"os"

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

var (
	nr       = noresponse.NewClient(os.Getenv("NORESPONSE_API_KEY"))
	verifier *oidc.IDTokenVerifier
)

func init() {
	usermanagement.SetAPIKey(os.Getenv("WORKOS_API_KEY"))
	jwksURL, err := usermanagement.GetJWKSURL(os.Getenv("WORKOS_CLIENT_ID"))
	if err != nil {
		panic(err)
	}
	// go-oidc checks the signature and expiry of any JWT and caches the keys.
	keySet := oidc.NewRemoteKeySet(context.Background(), jwksURL.String())
	verifier = oidc.NewVerifier("", keySet, &oidc.Config{SkipClientIDCheck: true, SkipIssuerCheck: true})
}

// sessionCreatedAt returns when a session started, or "" when the user has no
// such session. The WorkOS Go SDK has no call to list a user's sessions, so it
// reads GET /user_management/users/{id}/sessions page by page.
func sessionCreatedAt(ctx context.Context, userID, sessionID string) (string, error) {
	after := ""
	for {
		query := url.Values{"limit": {"100"}}
		if after != "" {
			query.Set("after", after)
		}
		endpoint := "https://api.workos.com/user_management/users/" + url.PathEscape(userID) + "/sessions?" + query.Encode()
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
		if err != nil {
			return "", err
		}
		req.Header.Set("Authorization", "Bearer "+os.Getenv("WORKOS_API_KEY"))
		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			return "", err
		}
		var page struct {
			Data []struct {
				ID        string `json:"id"`
				CreatedAt string `json:"created_at"`
			} `json:"data"`
			ListMetadata struct {
				After string `json:"after"`
			} `json:"list_metadata"`
		}
		if resp.StatusCode != http.StatusOK {
			resp.Body.Close()
			return "", fmt.Errorf("workos list sessions: %s", resp.Status)
		}
		err = json.NewDecoder(resp.Body).Decode(&page)
		resp.Body.Close()
		if err != nil {
			return "", err
		}
		for _, s := range page.Data {
			if s.ID == sessionID {
				return s.CreatedAt, nil
			}
		}
		if page.ListMetadata.After == "" {
			return "", nil
		}
		after = page.ListMetadata.After
	}
}

func AssessAccountCreation(ctx context.Context, accessToken, ip string) (*components.Assessment, error) {
	// 1. Verify the access token, then read the user and the token's session.
	tok, err := verifier.Verify(ctx, accessToken)
	if err != nil {
		return nil, err
	}
	var claims map[string]any
	if err := tok.Claims(&claims); err != nil {
		return nil, err
	}
	usr, err := usermanagement.GetUser(ctx, usermanagement.GetUserOpts{User: tok.Subject})
	if err != nil {
		return nil, err
	}
	sid, _ := claims["sid"].(string)
	started, err := sessionCreatedAt(ctx, usr.ID, sid)
	if err != nil {
		return nil, err
	}

	// 2. The adapter maps the claims; the records supply the rest.
	overrides := map[string]any{
		"email_verified":     usr.EmailVerified,
		"account_created_at": usr.CreatedAt,
	}
	if started != "" {
		overrides["session_created_at"] = started
	}
	authContext, err := noresponse.FromWorkOSClaims(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.
	res, err := nr.Assessments.Create(ctx, noresponse.NewIdempotencyKey(), components.AssessmentRequest{
		AccountID:   usr.ID,
		Identifiers: components.AssessmentRequestIdentifiers{Email: noresponse.Pointer(usr.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 WorkOS user id is the `account_id`. It is stable, and the WorkOS 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 verification gave you. 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 in an organization, with a verified email:

```json title="auth_context for a new user" theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  "provider": "workos",
  "issuer": "https://api.workos.com/user_management/client_01JC5Q8ZRB2V7X3N4M6P8T0W2Y",
  "subject": "user_01JC5QA4K7M2N9P3R5T7V9X1Z3",
  "session_id": "session_01JC5QB6D8F0H2J4L6N8Q0S2U4",
  "token_id": "01JC5QC8G0J2L4N6Q8S0U2W4Y6",
  "issued_at": "2026-10-21T16:41:50Z",
  "expires_at": "2026-10-21T16:46:50Z",
  "organization_id": "org_01JC5QD0K2M4P6R8T0V2X4Z6B8",
  "roles": ["member"],
  "impersonated": false,
  "email_verified": true,
  "account_created_at": "2026-10-21T16:41:48Z",
  "session_created_at": "2026-10-21T16:41:49Z",
  "verified": true,
  "transport": "backend"
}
```

That context fires three 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` lowers it. It makes 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.