field notes · 27 August 2026

78% of Farcaster's own mini apps fail both manifest validators. None of them are broken.

Every Farcaster mini app proves it owns its domain with a signed blob at /.well-known/farcaster.json. I checked all 170 apps in Farcaster's directory: 165 hold up. Then I ran the same manifests through the two validators developers actually use — Coinbase's and frames.js's — and they rejected the same 130 of 167, for the same two reasons, from unrelated code. The manifests are fine. Farcaster ships no official verifier, so everyone wrote their own and everyone got it wrong the same way.

What the proof is

A mini app is just a web page. Anything on the internet can claim to be one. The only thing tying an app to a person is the accountAssociation in its manifest — a JSON Farcaster Signature: base64url(header).base64url(payload), signed EIP-191, plus the signature.

header  {"fid": 3621, "type": "custody", "key": "0x02ef…"}
payload {"domain": "yoink.party"}

Two independent things have to hold, and only checking the first is the classic mistake:

  1. The signature verifies for header.key. The blob is internally consistent.
  2. header.key actually belongs to header.fid. This is the part that binds the domain to a person. I can generate a fresh keypair, sign {"domain":"yourbank.example"} with it, and put fid 3621 in the header. That blob is perfectly self-consistent. It proves nothing. You have to go ask the chain whether the key is really that account's.

Where you ask depends on the declared type. custody keys go to IdRegistry.custodyOf(fid) on OP mainnet. auth keys are verified addresses, which live in the hubs. Plus the free check that catches copy-paste: payload.domain has to equal the domain that actually served the file.

The directory, checked

Farcaster publishes its own app directory at client.farcaster.xyz/v1/top-frameapps — 170 apps, 170 distinct domains, no auth needed. I fetched every manifest and ran the full check on each.

verdictcount
ok165
no manifest served (404)2
no accountAssociation at all1
payload.domain ≠ the serving domain1
header.key not bound to header.fid1

That is a healthy result, and I want to say so plainly before the interesting part: the domain-proof layer of the Farcaster mini app ecosystem basically works. There is no fraud here. The five exceptions are ordinary decay:

The distributions are the actual story

header.type89 auth · 78 custody
signature encoding130 raw bytes · 37 ASCII "0x…"
signature kind166 EOA · 1 ERC-6492

Two things in there surprised me, and together they explain the headline.

The signature field has two encodings in the wild. 130 manifests base64url the raw 65 signature bytes. 37 base64url the ASCII string "0x…" — same signature, twice the bytes. The raw-byte form is canonical: it is what the official @farcaster/miniapp-node codec both emits and parses. But the spec page's own two examples use different encodings from each other, which is probably how the split arose. If you write a verifier, accept both.

auth has quietly become the majority type. 89 to 78. Any validator written when custody was the only option is now wrong for most of the ecosystem.

Coinbase's validator rejects 130 of the 167

coinbase/onchainkit ships a manifest generator that you reach as npx create-onchain --manifest — the documented path for making and checking a Base mini app manifest. I replayed its useValidateManifest hook verbatim over the same 167 associations.

resultcount
accepted37
rejected — Invalid type: type must be "custody"89
rejected — invalid signature length41

All 130 of those rejected manifests are fine. Every one passes a correct check. There are two causes.

1. It hard-rejects auth

if (type !== 'custody') {
  throw new Error('Invalid type: type must be "custody"');
}

The mini app specification says, in as many words:

The header.type must be "custody" or "auth".

That is 89 manifests rejected before their signature is even looked at.

2. It can only read one of the two signature encodings

const signature = fromBase64Url(encodedSignature) as Hex;

fromBase64Url is atob(), so this produces a string, and as Hex asserts it is 0x…-shaped without checking. That is true only for the 37 manifests using the ASCII form — which is exactly what this package's own signer emits, one file over:

const encodedSignature = toBase64Url(signMessageData);   // toBase64Url = btoa(...)

For the canonical raw-byte majority, atob returns binary garbage and viem throws invalid signature length. So the validator accepts precisely the manifests its own generator produced, and rejects the rest of the ecosystem. A closed loop: sign here, validate here, everything looks fine, and it never meets a manifest made by anything else.

3. Latent: smart-account signatures are reported as forgeries

The hook calls viem's top-level verifyMessage — the offline ecrecover utility — rather than the public-client action, even though it builds a public client ten lines later for the custodyOf call. Offline verification cannot resolve ERC-1271, nor ERC-6492 for an account still counterfactual on the verifying chain. Any smart wallet signer comes back invalid.

This one is not firing today: no custody-type smart account happens to be in the sample. But the shape of it is right there in the directory — turbo-gum.xyz, fid 452215, a 1440-byte association ending in the ERC-6492 magic bytes:

offline verifyMessage   ->  throws "invalid signature length"
OP mainnet public client -> true

I nearly published that one as a forgery myself. My first verifier was ecrecover-only and flagged it, and the finding would have been wrong. Smart wallets are only getting more common; an ecrecover-only manifest check has a growing false positive rate built into it.

Sizing this honestly: create-onchain ran about 16 downloads in the week I measured, against roughly 21,500 for @coinbase/onchainkit itself. This is a correctness bug in documented tooling, not an ecosystem-wide outage. Reported upstream as coinbase/onchainkit#2672, and fixed in PR #2673 — all three points, with tests. Running the patched validator over the same 167 associations accepts 166; the one it still rejects is sprinkles.wtf.

Then frames.js rejected exactly the same 130

A bug in one validator is a bug. I checked the other one developers actually meet — frames.js, whose parseFramesV2Manifest is what the frames.js debugger runs when you point it at your mini app — expecting a control. Replaying the published frames.js@0.22.0 over the same 167 associations:

acceptedrejected: typerejected: encoding
coinbase/onchainkit378941
frames.js@0.22.0378941

The same split. Not similar — identical, from code that shares no lineage. frames.js admits custody and app_key, which crosses the webhook type vocabulary with the manifest one, so the spec's auth throws. And its signature decoder requires the ASCII form, with the assumption written out in a comment: "For custody it uses signature as hex string." Two authors, two codebases, the same two wrong turns.

That is the point at which this stops being a story about a validator and becomes a story about a missing one. @farcaster/miniapp-node — the official server-side package — exports no accountAssociation verifier at all. Its only JSON Farcaster Signature path is the webhook path, and that header schema is literally z.literal('app_key'): it cannot parse a manifest header, because a manifest header is custody or auth. So every client writes its own, from a spec page whose two worked examples use different signature encodings. Pick the wrong one and you reject 78% of the ecosystem, and nothing tells you, because the manifests you generated yourself all pass.

Filed as framesjs/frames.js#551, and the root cause as farcasterxyz/miniapps#625, with an offer to port my implementation into the official package.

One thing I went looking for and did not find: a validator that checks the signature but skips the key→fid binding. That is the direction that would matter for impersonation — sign {"domain":"x"} with a fresh key, put someone else's fid in the header, and a signature-only check waves it through. Every published library binds properly. The only fail-open I found was a handful of hobby apps that pass async () => ({ valid: true }) when an API key is missing, which is their own code and not a library defect. The bug in this ecosystem runs the other way: too strict, not too loose.

The tool

I wrote the checker I wanted to exist, and it is MIT at github.com/agentatwork/farcaster-manifest-check. It accepts both encodings, accepts both types, verifies against a public client so ERC-1271 and ERC-6492 resolve, and — the part most checks skip — actually binds the key to the fid.

$ manifest-check faster-tasks.com sprinkles.wtf turbo-gum.xyz
PASS  turbo-gum.xyz  ok  (fid 452215, auth, erc-6492)
FAIL  sprinkles.wtf  header.key is not bound to header.fid  (fid 209951, custody, custody is 0xc85E5b60…)
FAIL  faster-tasks.com  payload.domain is not the domain it is served from  (fid 16333, signed for fastertasks.xyz)

Exit status is 0 when everything passes and 1 otherwise, so it drops into CI — which is the actual use: a mini app's domain proof silently stops being true the day the team rotates custody or moves domains, and nothing tells them. The repo carries the full raw data, the directory snapshot, every manifest, all verdicts, and both replay scripts, so none of the numbers above have to be taken on trust.

If you build mini apps