Cloudflare Turnstile
Exempt dedicated test accounts from Turnstile using verified Composal signatures.
Contents
Exempt signed Verify requests
If Cloudflare Turnstile blocks your automated sign-in, your application can verify Composal’s Web Bot Auth signature and exempt a dedicated test account from its Turnstile check. Your normal password, session, authorization and MFA checks still apply.
This requires a change to both your sign-in page and its server endpoint. Cloudflare does not document a Turnstile dashboard setting that exempts a particular Web Bot Auth signer. An IP allowlist or WAF Skip rule alone does not remove an embedded Turnstile 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. Apply the exemption only to the test hostnames and authentication actions you intend to automate.
Hosted Verify scenario runs and Discovery browsers supply signed requests to their configured HTTPS application hosts. Composal keeps its private key on the server; the browser receives signatures that expire within one minute and refreshes them during the run. Local CLI browsers and owner-operated login or recording browsers do not currently receive this signing grant.
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 authority, including a 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 so key rotation can work. Do not copy Cloudflare Browser Run’s detection IDs: those identify Cloudflare’s own browsers.
Cloudflare’s review controls its recognition of our registered bot. Your application can independently verify our published cryptographic identity while that review is pending. A valid signature identifies the Composal 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:
npm install web-bot-auth@0.2.0
Keep this module on the server. It fetches keys from the fixed Composal directory, accepts only Composal’s identity and Ed25519, and checks the signature’s age and lifetime:
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 signatures are short-lived operator attestations, not single-use login tokens: do not use them as authorization, a session cookie or permission to skip authentication.
Apply the same exemption on the page and server
- Choose the scope. Allow only 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.
- Decide whether to render Turnstile. On the sign-in page’s server request, verify the signature and check your host and endpoint policy. If your page is rendered in the browser, fetch a same-origin policy endpoint that makes this decision on the server. Return
requiresTurnstile: falseonly for a verified, in-scope request. UseCache-Control: private, no-storefor this response and any personalized sign-in HTML. - Use that result on the page. For exempt requests, do not mount the widget, wait for its callback or block submission on a missing Turnstile token. Other visitors keep the existing widget and submission behavior.
- Verify the form submission again. Recompute the signature and scope decision 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.
- Restrict the account and retain authentication. For an exempt request, reject an account that is not explicitly approved for testing. For a non-exempt request, require a valid Turnstile token and call Siteverify as usual. Then run your normal authentication and authorization checks in both cases.
The server decision should have this shape; adapt the account lookup and authentication functions to your application:
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.
Your rendering policy and POST policy must agree about which signed requests are in scope. Changing only the page leaves Siteverify rejecting submissions. Changing only the server leaves the browser waiting on the widget. Cloudflare describes this two-sided approach in its conditional enforcement guide.
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. Reprepare runs after changing their target configuration. Signatures expire within a minute; the browser stops obtaining new signatures when its grant or run is no longer authorized.
Log the run identifier and a verification outcome 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 authority, verification should fail and preserve Turnstile enforcement.
For a staging-only alternative, use Cloudflare’s paired test sitekey and secret key. Keep those keys out of production. Saved browser sessions can help with a one-off sign-in, but expire and do not provide a durable exemption.
Handle WAF and bot rules separately
An application exemption takes effect when the request reaches your server. A Cloudflare WAF or bot rule may still challenge or block it first. Resolve that separately through a narrowly scoped rule or dedicated outbound IP allowlist.
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 for its current registration and recognition behavior.
Cloudflare recognition, WAF access and your application’s Turnstile exemption are separate checks. Turnstile pre-clearance also does not replace your application’s server-side token validation. Contact us with your run URL and affected hostname if you need help confirming which check failed.