← Blog
October 11, 2026 · 18 min

Privy and Turnkey policy engines vs x402: what the signer sees

The last spend control an autonomous agent has is the policy engine sitting next to its key. For an x402 payment, that engine never sees a transaction. It sees an EIP-712 message, and the shape of that message depends on which wallet adapter built it. We captured those shapes for Privy and Turnkey and read both policy languages to find where a cap actually binds.

Managed wallet providers sell the same promise to agent builders. The agent never holds the private key. Keys live in an enclave. A policy engine inside that enclave decides what the agent may sign. If the agent is prompt-injected, or its code is compromised, the policy still holds.

That promise was written for transactions. An x402 payment on EVM is not a transaction. The agent signs an EIP-3009 TransferWithAuthorization or a Permit2 message. A facilitator submits it and pays the gas. Every rule written against eth.tx.value or a transaction's to field misses it entirely.

Both Privy and Turnkey know this, and both engines can parse EIP-712 typed data. This audit asks what a developer gets by following their x402 guides, and what neither engine can express yet.

Sources: Privy and Turnkey documentation as fetched on 11 October 2026; @x402/evm 2.28.0; viem 2.57.4; @turnkey/viem 0.14.45, published 8 October; @privy-io/node 0.35.0; the Coinbase CDP Policy Engine reference; and our own gateway's live 402 response. We did not open Privy or Turnkey accounts. Wherever an outcome depends on a closed policy evaluator, we say so.

The short version:

One payment, three shapes

Before looking at the engines, it helps to know exactly what an x402 client asks a wallet to sign. We read the client code in @x402/evm 2.28.0. There are three shapes.

Exact, EIP-3009. This is the default. The client signs one TransferWithAuthorization message. The domain is the token's own: name and version from the seller's extra field, the chain ID, and the token address as verifyingContract. The message holds from, to, value, validAfter, validBefore and a random 32-byte nonce. The client sets validAfter to 0 and validBefore to the current time plus the seller's maxTimeoutSeconds.

Exact, Permit2. When the seller sets assetTransferMethod: "permit2", the client signs a PermitWitnessTransferFrom under the Permit2 domain at 0x000000000022D473030F116dDEE9F6B43aC78BA3. The amount and token sit in a nested permitted struct. The spender is the x402 exact proxy, 0x402085c248EeA27D92E8b30b2C58ed07f9E20001. The recipient is not a top-level field. It sits in a nested witness struct with two fields, to and validAfter.

Upto, Permit2. The metered scheme we covered in our upto post uses the same primary type with a different spender, 0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002, and a different witness. Its Witness type has three fields: to, facilitator and validAfter.

The Permit2 paths can add a second signature. If the wallet has not yet approved Permit2 and the seller advertises a gas-sponsoring extension, the client signs one more thing. We ran the client against a test signer to see what. With eip2612GasSponsoring, it signed a second typed-data message: an EIP-2612 Permit on the USDC domain, for the exact payment amount. With erc20ApprovalGasSponsoring, it signed a raw transaction: approve(Permit2, 2^256 - 1) on the USDC contract. When the signer could not read chain state, the client skipped both extensions.

That last case matters for policy authors. An unlimited Permit2 approval means any later Permit2 signature from that wallet can move its USDC. A policy that allows Permit2 messages must therefore pin the spender to the x402 proxies, not just the token.

What reaches the signer

Policy engines evaluate the payload they receive, not the payload the x402 client intended. So we captured that payload. The harness ran ExactEvmScheme and UptoEvmScheme from @x402/evm 2.28.0 against three signers, with every backend mocked and no network calls. The Turnkey mock records the signRawPayload request and signs the EIP-712 hash of exactly the JSON it was given, which is what any server-side typed-data signer must do. The Privy mock records the signTypedData body. The request was a 0.01 USDC payment on Base.

# exact / EIP-3009 — Turnkey account passed straight to ExactEvmScheme
encoding   PAYLOAD_ENCODING_EIP712   hashFunction HASH_FUNCTION_NO_OP
types      ["TransferWithAuthorization"]
domain     {}
message    {"from":"0x19e7…","to":"0x2096…","value":"10000","validAfter":"0",
            "validBefore":"1791709666","nonce":"0xa9a0…"}
verifies against USDC's domain: false

# exact / EIP-3009 — same Turnkey account, wrapped in a viem WalletClient
types      ["EIP712Domain","TransferWithAuthorization"]
domain     {"name":"USD Coin","version":"2","chainId":8453,
            "verifyingContract":"0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"}
message    addresses lowercased, uint256 values as decimal strings
verifies against USDC's domain: true

# exact / EIP-3009 — Privy createViemAccount (what createX402Client uses)
types      ["TransferWithAuthorization"]
domain     {"name":"USD Coin","version":"2","chainId":8453,
            "verifyingContract":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"}
message    {"value":"0x2710","validAfter":"0x0","validBefore":"0x6acb51e2",…}
            checksummed addresses, uint256 values as hex strings
verifies against USDC's domain: true

The Permit2 runs followed the same pattern. The raw Turnkey account again sent an empty domain. Through a WalletClient, Turnkey received the Permit2 domain, but only top-level addresses were lowercased. The nested permitted.token and witness.to arrived in checksummed case. Privy received the full domain, the three struct types, and hex-encoded numbers.

Three different wire formats for one payment. Each engine's rules have to match the one it actually gets.

Turnkey: the domain that never leaves the process

Turnkey's agentic payments guide shows the x402 integration in two lines. Create an account with createAccount from @turnkey/viem. Register new ExactEvmScheme(turnkeyAccount). The guide says the scheme "accepts any viem account, so the Turnkey signer works as a drop-in replacement."

It is not a drop-in, for a reason that sits in viem. @turnkey/viem implements signTypedData by calling viem.serializeTypedData and sending the result with PAYLOAD_ENCODING_EIP712. In viem 2.57.4, serializeTypedData returns an empty domain when the types map has no EIP712Domain entry. The x402 client never includes one. Its type map for EIP-3009 holds only TransferWithAuthorization.

A local viem account does not hit this, because viem derives EIP712Domain from the domain object before hashing. A viem WalletClient does not hit it either, because its signTypedData action adds the entry before calling the account. Turnkey's own older with-x402 example, built on x402 0.7.2, signs through a WalletClient. The current guide passes the raw account, so the domain is lost before the request leaves the process.

Turnkey's engineers have found this. tkhq/sdk#1520, a draft opened on 12 September, says direct account signing "currently discards the supplied EIP-712 domain when types.EIP712Domain is omitted, producing signatures that fail verification against the original input." It also warns that "domain-based policy decisions may change." An outside contributor proposed a similar fix in #1198 in February. Both are open. Five @turnkey/viem releases have shipped since 12 September, and 0.14.45 still has the old code.

Two consequences follow. First, a Turnkey agent on the documented path cannot complete an x402 payment: the signature does not match USDC's domain, so the facilitator rejects it. Second, any policy condition on eth.eip_712.domain has nothing to read. Until the fix ships, the workaround is the one the older example used: wrap the account in a WalletClient and pass a signer whose signTypedData goes through the WalletClient. In our harness, that produced a valid signature.

What Turnkey's language can say

Once the domain arrives, Turnkey's policy language is expressive. eth.eip_712 exposes primary_type, a domain with name, version, chain_id and verifying_contract, and a message map. Nested fields use bracket notation, so eth.eip_712.message['witness']['to'] reaches the Permit2 recipient. Arrays support .all(), .any() and .count().

Five details matter for x402.

The documented EIP-3009 example caps nothing. Turnkey's Ethereum examples allow EIP-3009 signing when domain.name == 'USD Coin' and the primary type is TransferWithAuthorization. There is no condition on value, on to, on the chain or on the token address. Copied as written, it lets the agent sign any USDC authorization, to anyone, for any amount.

The agent template does not allow x402 at all. Turnkey denies by default. The agentic wallets guide gives the agent a base signing policy for ACTIVITY_TYPE_SIGN_TRANSACTION_V2 and ACTIVITY_TYPE_ETH_SEND_TRANSACTION. Typed data goes through ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2, which that policy does not cover. Its spending cap is written as eth.tx.value > 500000000000000000, which no x402 payment will ever trigger.

Lowercase is a requirement. The docs ask for lowercase hex in EIP-712 policies and warn that uppercase "may result in errors or policy rejection." Yet the docs' own Permit2 examples compare against a checksummed USDC address. In our capture, nested addresses arrived checksummed. We could not test how the evaluator normalizes them. Test both casings before trusting a witness.to allowlist.

Raw signing is an escape hatch. The same activity type also signs arbitrary bytes with PAYLOAD_ENCODING_HEXADECIMAL and HASH_FUNCTION_NO_OP. That is a pre-computed digest, and a digest can be any EIP-712 hash. Turnkey documents a deny rule for exactly this case. Any allow rule for x402 should also pin activity.params.encoding == 'PAYLOAD_ENCODING_EIP712'.

Root and consensus change the picture. If a quorum of root users takes an action, Turnkey allows it without consulting policies, so an agent must never be a root user. Turnkey can also require a second approver above a threshold. With x402, that approval cannot block and wait. @turnkey/viem throws TurnkeyConsensusNeededError when an activity needs more approvals, and the x402 client fails the payment. A human approval tier has to sit outside the synchronous 402 retry.

One more limit: the language's integers are 128-bit. The Permit2 approval in the gas-sponsoring path is 2^256 - 1, outside that range. A policy can match the approve call and its spender, but it cannot compare that amount numerically.

Privy: exact types, or the rule never fires

Privy's x402 support is first-party. createX402Client in @privy-io/node builds an x402 client around a Privy wallet, and React apps get useX402Fetch from @privy-io/react-auth. The Node client registers the exact scheme for EVM and Solana. It does not register upto, and it passes no RPC configuration, so the Permit2 gas-sponsoring extensions never activate through it.

Privy's policy engine runs in the secure enclave. Rules are scoped per RPC method. A method with no rule is denied. DENY beats ALLOW. Typed data has two field sources: ethereum_typed_data_domain for chainId and verifyingContract, and ethereum_typed_data_message for dot-separated paths into the message, such as to, value or witness.to.

The message source has one sharp rule. Privy's examples page states it plainly. A typed-data message condition "only evaluates when the types map declared in the policy matches the types map in the signing request exactly," including field order. On a mismatch, "a DENY rule never fires, and a permissive ALLOW rule on the same method signs the request."

Privy's x402 sanctions-screening recipe goes further and lists which clients send which map. Its own createX402Client and useX402Fetch send TransferWithAuthorization only. A viem WalletClient, or any integration that builds the call itself, also sends EIP712Domain. The recipe names AgentCore Payments as one of those. Our capture matched the first row: no EIP712Domain, full domain, hex numbers.

This is the right design, and it is well documented. But it creates a failure mode that only shows up when you change clients. Say a team writes a DENY rule pinned to the Node client's map and a broad ALLOW rule pinned only to the domain. Later the team moves the agent behind a WalletClient-based framework. The request map gains EIP712Domain. The DENY rule stops matching. The ALLOW rule still passes. Nothing errors. Privy's recipe avoids this by pinning both rules to the same map, so a shape change fails closed. Copy that pattern, not the looser one.

The Permit2 paths make it worse. The exact and upto Witness types differ by one field. Under exact-match semantics, one rule cannot cover both. A wallet that pays both kinds of seller needs a rule per shape, per client.

Two more details:

Raw hashes have no rule method. Privy's viem adapter signs raw hashes through secp256k1_sign. That method is not in the SDK's PolicyMethod type, which ends with the wildcard '*'. With deny-by-default, a policy without a wildcard rule refuses raw-hash signing. Keep wildcard ALLOW rules off agent wallets.

Thresholds follow the wire format. The Node adapter converts every bigint to hex, so value arrives as "0x2710". Privy's own numeric examples write thresholds in hex. We could not confirm how the evaluator compares mixed formats, so match the hex convention.

What neither engine can say

Field-level rules cover one signature at a time. Three things an agent owner usually wants are out of reach on both platforms.

// Gap 1

A running budget

Privy has real stateful policies: aggregations that sum a field over a rolling window and feed the total into a condition. The supported methods are eth_signTransaction and eth_signUserOperation. The AggregationMethod type in @privy-io/node 0.35.0 has those two values and no others. Windows run from one hour to 72 hours, an app can have ten aggregations, and totals are recorded after signing, so concurrent requests can race past the limit. None of it applies to eth_signTypedData_v4, the method every EVM x402 payment uses.

Turnkey's language has no state. Its agentic-wallets guide describes spending caps as bounding "transaction values per-signing." Turnkey Verifiable Cloud can run a custom policy sidecar that votes on activities, and that sidecar could keep a ledger. It is in beta, and you write the ledger yourself.

// Gap 2

A time window relative to now

The x402 client sets validBefore to now plus the seller's maxTimeoutSeconds. An owner might want to refuse anything valid for more than ten minutes. Privy compares a field to a static value, and its current_unix_timestamp is a field, not something a condition can subtract. Turnkey's time.now is a timestamp compared against timestamp literals, and its grammar has no arithmetic. Neither can express "validBefore is at most 600 seconds from now." The window is whatever the seller asked for, unless the client checks it.

// Gap 3

A binding to the 402 itself

The engine sees a signing request, not the HTTP exchange that caused it. It cannot check that the authorization matches the seller's 402, or that the resource was delivered. The only link between policy and seller is the recipient address. An allowlist of payTo addresses is the strongest seller-level control either engine offers.

For completeness, we rechecked Coinbase's CDP Policy Engine reference, which we covered in our Wallet MCP audit. As of today, its typed-data criteria, evmTypedDataField and evmTypedDataVerifyingContract, exist only under signEndUserEvmTypedData. The operation list for server accounts has no typed-data entry. Its signEvmHash operation takes no criteria and can only be rejected outright.

Taken together, the three providers sit at different points. Privy can bound a single x402 signature precisely, if the types map is right. Turnkey can too, once its adapter keeps the domain. CDP can do it for end-user wallets but not server wallets. None of them can enforce a cumulative x402 budget in the engine today. That matches what we found at the smart-account layer in the Crossmint audit: on EVM, the running total is the part that keeps slipping out of reach.

A policy for paying LLM4Agents

Our walk-up endpoint's 402 is a simple target. We read it today with an unauthenticated request: scheme exact, network eip155:8453, asset Base USDC at 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913, payTo 0x0D741Ab0968906f8338C60f79b81B49c23258C12, maxTimeoutSeconds 300, and extra set to "USD Coin" version "2". A gpt-5 call with max_tokens 256 was quoted at 10000, or 0.01 USDC. That is the EIP-3009 shape: one message, one types map.

Here is a Privy rule for the Node and React x402 clients, capped at 0.25 USDC per signature. It pins chain, token, recipient and amount, and it declares the exact map those clients send.

const twa = {
  types: { TransferWithAuthorization: [
    { name: 'from', type: 'address' }, { name: 'to', type: 'address' },
    { name: 'value', type: 'uint256' }, { name: 'validAfter', type: 'uint256' },
    { name: 'validBefore', type: 'uint256' }, { name: 'nonce', type: 'bytes32' } ] },
  primary_type: 'TransferWithAuthorization',
};

{
  version: '1.0', name: 'Agent pays LLM4Agents on Base', chain_type: 'ethereum',
  rules: [{
    name: 'LLM4Agents, max 0.25 USDC per signature',
    method: 'eth_signTypedData_v4', action: 'ALLOW',
    conditions: [
      { field_source: 'ethereum_typed_data_domain', field: 'chainId', operator: 'eq', value: '8453' },
      { field_source: 'ethereum_typed_data_domain', field: 'verifyingContract', operator: 'eq',
        value: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' },
      { field_source: 'ethereum_typed_data_message', typed_data: twa, field: 'to', operator: 'eq',
        value: '0x0D741Ab0968906f8338C60f79b81B49c23258C12' },
      { field_source: 'ethereum_typed_data_message', typed_data: twa, field: 'value', operator: 'lte',
        value: '0x3D090' }, // 250000 = 0.25 USDC
    ],
  }],
  // no '*' rule: every other method stays denied by default
}

And the Turnkey equivalent, for a Turnkey account signing through a WalletClient until the domain fix ships. Addresses are lowercase, as Turnkey's docs require.

{
  "policyName": "Agent pays LLM4Agents on Base, max 0.25 USDC per signature",
  "effect": "EFFECT_ALLOW",
  "consensus": "approvers.any(user, user.id == '<AGENT_USER_ID>')",
  "condition": "activity.type == 'ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2'
    && activity.params.encoding == 'PAYLOAD_ENCODING_EIP712'
    && wallet.id == '<AGENT_WALLET_ID>'
    && eth.eip_712.primary_type == 'TransferWithAuthorization'
    && eth.eip_712.domain.chain_id == 8453
    && eth.eip_712.domain.verifying_contract == '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913'
    && eth.eip_712.message['to'] == '0x0d741ab0968906f8338c60f79b81b49c23258c12'
    && eth.eip_712.message['value'] <= 250000"
}

The condition is shown on several lines for reading. In the API it is one string. Pair it with Turnkey's documented deny rule for HASH_FUNCTION_NO_OP payloads that are not EIP-712.

Test before trusting — we wrote both policies against the documented grammars. We did not run them on Privy's or Turnkey's evaluators. Before relying on either, send three payments from a test wallet: a normal one, one with a different payTo, and one above the cap. Only the first should sign. A rule that never fires looks exactly like a correct rule until you test the case it should block.

What it means for LLM4Agents

A broken buyer signer looks like a broken seller. A Turnkey agent following the documented x402 path sends us a signature over an empty domain. Verification fails and the agent gets another 402. From the buyer's side, our endpoint simply refuses to accept payment. We should recognize that case and say what it is.

Our 402 is the easy shape, and that is worth keeping. Exact EIP-3009 means one message type, one types map, the token's own domain and a top-level recipient. That is the shape every engine in this audit handles best. If we add Permit2 or upto paths, buyers need more rules, a nested-path recipient check and possibly an unlimited Permit2 approval. That cost belongs in the decision.

Our payTo is now part of other people's security config. A buyer with a recipient allowlist pins our address in their policy. If we rotate it, every such buyer fails closed at once, with no error on our side. The payTo has to be treated as a published, versioned constant.

Our 300-second window is the window buyers get. Neither engine can bound validBefore relative to now, so a buyer's signer cannot refuse a long window from us. Keeping maxTimeoutSeconds short is our responsibility, not theirs.

Per-signature caps meet our quote. Every cap in this audit compares against the amount in the authorization, which is our worst-case quote, not the final bill. As in the x402 SDK spend-cap audit, a tight buyer cap refuses calls we quote high. Quote accuracy decides whether capped buyers can pay us at all.

Staying on the frontier

1. Detect the empty-domain signature. When a payment fails verification, recompute the EIP-3009 digest with an empty domain and see whether it recovers to from. If it does, return an error that says the buyer's signer dropped the EIP-712 domain and points to the WalletClient workaround. It is a few lines of code, and it turns a silent refusal into a support answer.

2. Publish a buyer policy kit. Ship the two policies above, plus a CDP end-user version, pinned to our chain, token, payTo and a suggested cap. Include the exact types maps for each client and the three test payments. Buyers with limits are the buyers we want, so make limits easy to set.

3. Run the matrix on real engines. Privy and Turnkey both offer self-serve accounts. On Base Sepolia, run the three test payments through each engine, plus hex versus decimal thresholds, checksummed versus lowercase addresses, and Turnkey's raw-account path. Publish what each evaluator actually does with the cases we could only infer.

4. Freeze and publish payTo and maxTimeoutSeconds. List both in our public docs as versioned values. Announce any change in advance, with an overlap period where both addresses are accepted.

5. Keep EIP-3009 exact as the default rail. If we add upto for metered calls, ship policy templates for its Witness shape at the same time, and never require the unlimited-approval extension.

6. Track the two upstream changes. Watch tkhq/sdk#1520 for the domain fix and Privy's aggregation method list for typed-data support. When either moves, rerun step three. A cumulative x402 cap inside the enclave would be the most useful buyer-side control in this stack.

Steps one and two are cheap and help buyers this week. Three replaces inference with measurement. Four and five keep us easy to allowlist. Six is where the frontier will move.

Be the easy seller to allowlist

OpenAI-compatible inference, paid per call in USDC over x402, with one stable payment shape.

Register your agent