Zac Sheffer · 2026.10.21 · 8 min read
Introzy has two ways in for someone who wants to hand us work.
The first is a web form. Customers publish partner sites and public referral forms, and anyone can fill one out. The second is our MCP server, where an assistant like Claude, ChatGPT, or Cursor connects on behalf of a signed-in user and calls typed tools against that user's organization.
Both doors end in the same place: a new contact or a referral deal in a customer's pipeline. They ask different questions on the way in. The form asks whether this submission is likely abuse. The MCP server asks who authorized this action, for which organization, and whether this tool is allowed at this level of trust.
The form is anonymous by design. The person filling it out has no Introzy account, and we want it that way, because a referral that requires signing up first mostly doesn't get sent.
Turnstile runs in the browser and hands the form a token. The token is worth nothing until the server checks it. Cloudflare's docs are direct about this: you call Siteverify from your backend, and if you expose the secret key in front-end code "attackers can bypass the security check." Tokens are valid for 300 seconds and can be validated only once. A replayed or stale token comes back as timeout-or-duplicate.
Our verifier is small, and it fails closed:
export async function verifyTurnstileToken(token: string): Promise<boolean> {
if (process.env.NODE_ENV === 'development') return true // local tokens can't pass the prod key
const secret = process.env.TURNSTILE_SECRET_KEY
if (!secret) return false // misconfiguration rejects
try {
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ secret, response: token }),
})
const data = (await res.json()) as { success: boolean }
return data.success === true
} catch {
return false // a network error is a rejection
}
}The bypass is keyed to development only. Test, staging, and anything we haven't thought of go through the real check. A missing secret, an HTTP failure, and a rejected token all return false, and each is logged with the surface it came from.
Turnstile is one control on that door. The rest matter as much:
This is the OWASP anti-automation playbook applied to one endpoint, and it applies the same way to a person, a script, or an agent driving a browser.
In July 2025 a Reddit user posted screenshots of OpenAI's ChatGPT Agent working through a video-conversion site. Its narration, as Ars Technica reported: "I'll click the 'Verify you are human' checkbox to complete the verification on Cloudflare. This step is necessary to prove I'm not a bot and proceed with the action."
That's one user's report of a checkbox, and it doesn't show a general Turnstile bypass. The broader pattern is documented by the vendors themselves. Browserbase, a hosted browser for agents, enables CAPTCHA solving by default for every session and asks customers to use it "only for workflows that users and website owners authorize." OWASP has had a name for the general problem for years: OAT-009, CAPTCHA Defeat.
Cloudflare's own research has moved past the one-time check. Its August post on good and bad agentic behaviors describes "hybrid" traffic, "where a single session shifts from human to agentic and back again," and notes that "suspicious behavior often happens mid-session, which point-in-time detection wouldn't catch."
Harder puzzles carry their own cost. The W3C's note on the inaccessibility of CAPTCHA covers who gets locked out as challenges get harder, and they're people we want filling out referral forms.
So we treat a valid token as a risk signal for one anonymous submission. It says nothing about who the submitter is or what they're allowed to do. Anything that needs an answer to "on whose behalf?" goes through the other door.
Agents can now sign their HTTP requests with HTTP Message Signatures, and Cloudflare verifies them through Web Bot Auth. OpenAI's cloud browser signs its requests this way, with a Signature-Agent header of "https://chatgpt.com" and a public key directory a site can verify against. In August 2025 Cloudflare added a signed agents category to separate these from crawlers, because "their remote browsers are used by their end customers, not necessarily the companies themselves."
A valid signature proves which company operates the agent. It doesn't prove which of that company's users sent it. Cloudflare's verified bots docs name the gap: an intermediary agent "acts on behalf of many different end users," which "introduces transitive trust: you may trust the intermediary operator, but not necessarily every end user driving it."
That's a good input for traffic policy. On a marketing site, it's a reasonable basis for letting a known agent read pages without a challenge, which fits why we let AI crawlers in. It can't stand in for a login, because a signed request from a large agent platform could come from any of its users, including one who has never heard of the customer whose pipeline they're writing to.
Our MCP server answers that question before any tool runs. We covered the design in detail in MCP as the CRM's tool surface. Four kinds of caller reach it:
| Caller | Credential | Trust level | What it can call |
|---|---|---|---|
| Assistants (Claude, Cursor) | OAuth 2.1 with PKCE; a person signs in | High | All 15 tools, subject to role |
| Scripts and headless clients | API key: 256 bits, hashed at rest, 90-day default | High | All 15 tools, subject to role |
| Our voice agent | Shared secret plus a caller already resolved upstream | Identified | A restricted set |
| Kai's intake workers | In-process, sender resolved before the call | High | A per-worker allowlist |
Every tool declares a minimum trust level, and the server checks it on every call. Two of the 15 require high trust today: enriching a contact and reporting deal revenue. Role checks run against the organization being written to, so "finance somewhere" doesn't count as finance here. Every query is scoped to that organization. The model doesn't make any of these decisions. The same pattern runs through Kai's intake agent, which calls the same tool handlers directly.
The MCP authorization spec puts the token rules in plain terms. Clients must send a resource parameter naming the server, under RFC 8707. Servers "MUST validate that access tokens were issued specifically for them" and "MUST NOT accept or transit any other tokens." A token meant for one server can't be replayed at another, and a server can't forward a client's token to some upstream API.
| Public form | MCP server | |
|---|---|---|
| Who's calling | Anyone, no account | A person's assistant, a script, or one of our own agents |
| Question at the door | Is this submission likely abuse? | Who authorized this, for which organization? |
| Main proof | Turnstile token checked server-side | OAuth access token or API key |
| What it can do | Create one referral or contact in one organization's pipeline | Read and write across the tools that caller's role and trust level allow |
| What it can't tell you | Who the submitter is | Whether the person behind the token meant this exact action |
A form submission is a claim from a stranger, and we treat it as one. An MCP call carries a real identity, but the assistant chose the action, so every write tool returns a link back into the app where a person can see what changed.
Writing this post meant reading our own code against current docs. Two things are on the list.
Check what Siteverify returns. Our verifier accepts any token where success is true. Cloudflare's reference implementation also compares the hostname and action in the response against what the server expected. For us the hostname check is the interesting one, because customer partner sites run on many custom domains. The expected hostname has to come from the site record for that form, not a constant. We'll also send the visitor IP and an idempotency_key, so a retried Siteverify call after a network blip doesn't burn a valid token.
Move off Dynamic Client Registration. Our OAuth flow lets assistants register themselves on first connect. That was a SHOULD in the June 2025 spec. The November 2025 revision added Client ID Metadata Documents, where a client identifies itself with an HTTPS URL that hosts its metadata. The July 2026 revision says plainly: "Dynamic Client Registration is deprecated. New implementations should use Client ID Metadata Documents instead." Existing clients will keep working for a while, since the spec keeps registration as a fallback. We're checking what our auth provider supports and will move when we can.
There's one idea we haven't built: pointing agents from the first door to the second. An agent that lands on a public referral form is usually acting for someone who already has an Introzy account. The form could tell it, in markup an agent can read, that there's an authenticated way to do this.
Cloudflare's "Beyond 'Are you human?' Advanced strategies for managing agentic traffic" session is this morning at 11 at Cloudflare Connect. I'll be there. If you run a product with both a public form and an agent surface, I'd like to hear where you drew the line. Our security page covers how we handle customer data more broadly, and earlier this week Evan and I wrote about what our coding agent is allowed to touch, which runs on the same principle from the other side.
Introzy is free to start. No card required.