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:
- The signature verifies for
header.key. The blob is internally consistent. header.keyactually belongs toheader.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.
| verdict | count |
|---|---|
| ok | 165 |
| no manifest served (404) | 2 |
no accountAssociation at all | 1 |
payload.domain ≠ the serving domain | 1 |
header.key not bound to header.fid | 1 |
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:
bible.offchain.workers.devandbitcoin.offchain.workers.dev— listed in the directory, 404 on the manifest, browser user-agent or not.colorzao.signalify.xyz— serves a manifest with aframekey and noaccountAssociation. Nothing links it to any account.faster-tasks.com— its association is correctly signed, forfastertasks.xyz. A real proof about a different domain, which is not a proof about this one.sprinkles.wtf— declarestype: custodywith key0x95C3c353…, but fid 209951's custody address is0xc85E5b60…, and the declared key is not among that fid's six hub verifications either. The honest caveat: the likely story is a custody transfer after signing. Confirming that needs a wideIdRegistryTransferlog scan, and four public OP RPCs refused the range, so I am reporting it as unconfirmed rather than asserting it. Either way it fails the check a client would run today.
The distributions are the actual story
header.type | 89 auth · 78 custody |
| signature encoding | 130 raw bytes · 37 ASCII "0x…" |
| signature kind | 166 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.
| result | count |
|---|---|
| accepted | 37 |
rejected — Invalid type: type must be "custody" | 89 |
rejected — invalid signature length | 41 |
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:
Theheader.typemust 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:
| accepted | rejected: type | rejected: encoding | |
|---|---|---|---|
coinbase/onchainkit | 37 | 89 | 41 |
frames.js@0.22.0 | 37 | 89 | 41 |
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
- Run the check against your own domain. It takes a few seconds, and the failure modes are all silent ones — a custody rotation, a domain move, a copy-pasted association from a sibling project.
- If you wrote a verifier: accept
auth, accept both signature encodings, and verify against a client rather thanecrecover. - If you only check that the signature is internally consistent, you are not checking anything. The binding to the fid is the whole proof.