# Cloudflare Turnstile

Exempt dedicated test accounts from Turnstile using verified Composal signatures.

## Exempt signed Verify requests

To exempt a dedicated test account from Turnstile, verify Composal’s Web Bot Auth signature in your application. Skip Turnstile only for approved requests, and keep your existing password, session, authorization and MFA checks.

Update both the sign-in page and its server endpoint. Cloudflare does not document a Turnstile dashboard exemption for individual Web Bot Auth signers. An IP allowlist or WAF Skip rule alone will not remove the embedded widget or its server-side token check.

You need control of the Turnstile integration, an HTTPS application and a dedicated test account saved in your project’s [Credentials](/docs/verify/credentials). Apply the exemption only to the test hostnames and authentication actions you intend to automate.

Hosted Verify scenario runs and Discovery browsers sign requests to their configured HTTPS application hosts. The private key stays on Composal’s server. Browsers receive signatures that expire within one minute and refresh them during the run. Local CLI browsers and browsers used for manual sign-in or recording do not currently sign requests.

## Composal’s bot identity

| Identifier | Value |
| --- | --- |
| Operator | Composal |
| Bot name submitted to Cloudflare | Composal Verify |
| Signature-Agent identity | `https://composal.ai` |
| Public key directory | `https://composal.ai/.well-known/http-message-signatures-directory` |
| Signature algorithm | Ed25519 |
| Signature tag | `web-bot-auth` |
| Current public key ID | `VTxaCm6ZDubSFYL-lfojhlyWNI9xUioXzeMd38snEwE` |
| Cloudflare registration | Submitted as an Intermediary; review pending |
| Cloudflare numeric bot or detection ID | Not assigned or published yet |

The outgoing header is a structured string: `Signature-Agent: "https://composal.ai"`. Requests also carry `Signature-Input` and `Signature` headers. The signature covers the destination host, including any non-default port, and the Signature-Agent value.

The key ID identifies a public key. It is not a Cloudflare bot ID, an organization ID or a test account. Read active keys from the directory to handle key rotation. Do not copy Cloudflare Browser Run’s detection IDs: those identify Cloudflare’s own browsers.

Cloudflare’s review determines whether it recognizes Composal as a registered bot. Your application can verify our signatures against the published keys while that review is pending. A valid signature identifies Composal as the operator. It does not identify your organization or authenticate an application user.

## Verify request signatures on your server

Preserve all three signature headers through your proxy. Verify against the public HTTPS URL that the browser requested. If your framework receives an internal HTTP URL, reconstruct the public URL from your deployment’s trusted configuration. Do not trust an arbitrary client-supplied Host or forwarded-host header.

For a JavaScript server using the standard `Request` interface, install Cloudflare’s [Web Bot Auth verifier](https://github.com/cloudflare/web-bot-auth):

```sh
npm install web-bot-auth@0.2.0
```

Run this module on your server. It fetches keys from the fixed Composal directory and accepts only Composal’s identity and Ed25519 signatures. It also checks the signature’s age and lifetime:

```js
import { verify } from 'web-bot-auth'
import { verifierFromJWK } from 'web-bot-auth/crypto'

const AGENT = 'https://composal.ai'
const DIRECTORY = `${AGENT}/.well-known/http-message-signatures-directory`
let keys = new Map()
let refreshAt = 0
let refreshing

async function trustedKeys() {
  if (Date.now() < refreshAt) return keys
  refreshing ??= refreshKeys().finally(() => { refreshing = undefined })
  return refreshing
}

async function refreshKeys() {
  const response = await fetch(DIRECTORY, {
    redirect: 'error',
    signal: AbortSignal.timeout(5000),
  })
  if (!response.ok) throw new Error('Key directory unavailable')
  const directory = await response.json()
  const verifiers = await Promise.all(directory.keys
    .filter(key => key.kty === 'OKP' && key.crv === 'Ed25519')
    .map(key => verifierFromJWK(key)))
  if (!verifiers.length) throw new Error('No trusted keys')
  keys = new Map(verifiers.map(key => [key.keyid, key]))
  refreshAt = Date.now() + 60_000
  return keys
}

export async function isComposalRequest(request) {
  try {
    const trusted = await trustedKeys()
    await verify(request, {
      algorithms: ['ed25519'],
      maxAge: 60,
      clockSkew: 5,
      resolver(candidate) {
        if (candidate.signatureAgent?.uri !== AGENT) {
          throw new Error('Unexpected operator')
        }
        const key = trusted.get(candidate.keyid)
        if (!key) throw new Error('Unknown key')
        return key
      },
      validate: ({ created, expires }) =>
        expires.getTime() > created.getTime() &&
        expires.getTime() - created.getTime() <= 60_000,
    })
    return true
  } catch {
    return false
  }
}
```

Never fetch a key URL supplied by the incoming request. A `Signature-Agent` header by itself is easy to forge. If verification, key refresh or parsing fails, follow your normal Turnstile path.

Keep server clocks synchronized. These short-lived signatures identify the Composal operator. Do not treat them as single-use login tokens or use them for authorization, session cookies or skipping authentication.

## Apply the same exemption on the page and server

1. Limit the exemption to your intended HTTPS hostnames and authentication endpoints. Keep a server-side allowlist or flag for dedicated test accounts. Do not exempt every user with a particular email domain.
2. Verify the signature on the sign-in page’s server request and check your host and endpoint policy. For a page rendered in the browser, fetch a same-origin policy endpoint that makes this decision on the server. Return `requiresTurnstile: false` only for a verified request within your allowed scope. Use `Cache-Control: private, no-store` for this response and any personalized sign-in HTML.
3. Omit the widget on the page for exempt requests. Do not wait for its callback or require a Turnstile token before submission. Keep the existing widget and submission behavior for other visitors.
4. Verify the signature and check the scope again on the actual login or signup POST. Do not trust the page’s policy result, a query parameter, user agent, hidden field or client-supplied exemption flag.
5. Reject exempt requests for any account you have not explicitly approved for testing. For requests that are not exempt, require a valid Turnstile token and call Siteverify as usual. Run your normal authentication and authorization checks in both cases.

Use this pattern on the server, with your application’s account lookup and authentication functions:

```js
const exempt = isAllowedTestHostAndAction(request) &&
  await isComposalRequest(request)

if (exempt) {
  if (!await isDedicatedTestAccount(input.email)) {
    return new Response('Test account required', { status: 403 })
  }
} else {
  await requireValidTurnstileToken(input.turnstileToken)
}

return normalSignIn(input) // Still checks password, MFA and account policy.
```

Use the same scope rules for the page and POST. If you change only the page, Siteverify still rejects submissions. If you change only the server, the browser still waits for the widget. See Cloudflare’s [conditional enforcement guide](https://developers.cloudflare.com/turnstile/tutorials/conditionally-enforcing-turnstile/) for both parts.

## Check the integration with a fresh run

Start a new hosted Verify run with a fresh browser session and a dedicated account. Check these cases:

| Request | Expected result |
| --- | --- |
| Valid Composal signature, approved host and test account | Widget omitted; normal sign-in succeeds with correct credentials |
| Valid signature with a wrong password or failed MFA | Sign-in fails |
| Valid signature for an unapproved account | Exemption rejected |
| Missing, altered or expired signature; another operator or destination | Normal Turnstile checks remain required |
| Ordinary visitor without signed headers | Existing Turnstile flow continues to work |
| Frontend bypass flag sent without a valid POST signature | Normal Turnstile checks remain required |

Check the signature on the actual sign-in POST, including requests to a separate API hostname. Save that hostname as a target for the run when needed. Prepare the run again after changing its targets. The browser stops receiving new signatures when its signing permission or run is no longer authorized. Existing signatures expire within a minute.

Log the run identifier and a signature verification result such as `valid`, `expired` or `unknown-key`. Avoid logging credentials, full signature headers or authentication tokens. If the proxy strips headers or rewrites the public host or port, signature verification should fail and your application should require Turnstile.

In staging, you can instead use Cloudflare’s paired [test sitekey and secret key](https://developers.cloudflare.com/turnstile/troubleshooting/testing/). Keep those keys out of production. Saved browser sessions expire, so they can help with a one-off sign-in but cannot provide a lasting exemption.

## Handle WAF and bot rules separately

An application exemption applies only after a request reaches your server. A Cloudflare WAF or bot rule may challenge or block it first. If that happens, use a narrowly scoped rule or [dedicated outbound IP allowlist](/docs/verify/private-networks#cloudflare-waf).

Do not allow all verified bots or all signed agents just to permit Composal. Fields such as `cf.bot_management.verified_bot` and `cf.bot_management.signed_agent` describe broader groups and may require a Cloudflare subscription. Our numeric Cloudflare bot ID is pending; there is no Composal detection ID to paste into a rule yet. See Cloudflare’s [Web Bot Auth documentation](https://developers.cloudflare.com/bots/reference/bot-verification/web-bot-auth/) for its current registration and recognition behavior.

Turnstile pre-clearance does not replace your application’s server-side token validation. If requests still fail, [contact us](/contact) with your run URL and affected hostname so we can help identify which check failed.
