Engineering
Engineering

Two doors: Turnstile for people, MCP for agents

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.

Door one: the public form

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:

  • Rate limits. The external referral form allows 10 submissions a minute per IP and form.
  • One error for everything. A bad token, an unknown form, a disabled form, and a failed business rule all return the same generic 400. The specific reason goes to our logs. Someone probing the form learns nothing about which check they tripped. Only the rate limit gets its own message, because it's expected and self-explanatory.
  • Duplicates collapse. A repeat submission inside the duplicate window returns the existing deal instead of creating a second one.
  • Plan limits still apply. A form can't create more active referrals than the customer's plan allows.
  • One organization per form. A submission lands in the pipeline of the organization that published the form, and lookups never span organizations.

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.

A passed challenge isn't permission

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.

Between the doors: signed agents

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.

Door two: MCP, where the question is "on whose behalf?"

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:

CallerCredentialTrust levelWhat it can call
Assistants (Claude, Cursor)OAuth 2.1 with PKCE; a person signs inHighAll 15 tools, subject to role
Scripts and headless clientsAPI key: 256 bits, hashed at rest, 90-day defaultHighAll 15 tools, subject to role
Our voice agentShared secret plus a caller already resolved upstreamIdentifiedA restricted set
Kai's intake workersIn-process, sender resolved before the callHighA 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.

Side by side

Public formMCP server
Who's callingAnyone, no accountA person's assistant, a script, or one of our own agents
Question at the doorIs this submission likely abuse?Who authorized this, for which organization?
Main proofTurnstile token checked server-sideOAuth access token or API key
What it can doCreate one referral or contact in one organization's pipelineRead and write across the tools that caller's role and trust level allow
What it can't tell youWho the submitter isWhether 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.

What we're tightening

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.

At Connect today

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.

We ship in public. Come build with us.

Introzy is free to start. No card required.