I shipped my comment plugin to a service that could never accept it
I’ve been rebuilding the comment system on this site with Claude Code. Visitors sign in with their own website to leave a comment — IndieAuth, the way it’s meant to work.
Overnight we migrated it from IndieAuth to IndieLogin.com. At 05:40 this morning it published to npm and deployed. By 08:00 I’d opened an incognito window, typed in my own domain, and got this:
Request Error
This client_id is not registered (https://rmendes.net)
The migration could never have worked. Not “had a bug” — could never have worked, for any visitor, on any site not already on a list I didn’t know existed.
Why it looked right
The reasoning behind the change was sound, which is what makes it worth writing up.
The plugin discovered each visitor’s own authorization_endpoint and sent them there. Visitors without one fell back to indieauth.com. That fallback is the part that dates: indieauth.com’s own home page says it “will eventually be replaced,” and points developers at IndieLogin.com.
IndieLogin also promised more providers — Bluesky, GitLab, Codeberg — and one genuinely appealing property. When you discover each visitor’s endpoint, you inherit an obligation from IndieAuth §5.4: if the profile URL the server returns isn’t the one the visitor typed, you must re-discover it and confirm it declares the same authorization endpoint. Skip that, and any authorization endpoint can return any me and be believed — including mine. My plugin was skipping it. Delegating to one trusted service makes that whole class of problem structurally impossible rather than merely fixed.
Good argument. Built on documentation nobody tested.
The part the docs don’t mention
IndieLogin’s API docs describe client_id as informational: “the home page of the application the user is signing in to.” Nothing about registration.
The source says otherwise. In app/Authenticate.php, on the authorize path:
$client = ORM::for_table('clients')->where('client_id', $client_id)->find_one();
if(!$client) {
$errors[] = 'This client_id is not registered (...)';
}
That table is queried in two places and written in none. Registration happens by opening a GitHub issue asking to be added. There’s a queue of them, requests from April and August this year sit unregistered, and an issue asking for a self-service registration form has been open since 2018.
The ideal would have been a solution to automate client website registration, there is a github issue for it, but to the best of my knowledge it has not been developed.
When Claude first told me this, I didn’t believe it — it sounded like a hallucination. aaronpk advocates for this stuff; the idea he’d maintain a manual allowlist seemed absurd. It restated the claim twice before I pushed hard enough that it stopped arguing and designed a test instead: same endpoint, same parameters, a deliberately-broken redirect_uri on both.
indiebookclub.biz→ “The client_id and redirect_uri must be on the same domain” appears to indicate this site is on the list of allowed clients.rmendes.net→ “This client_id is not registered” indicate I’m not the list and the opened github issues appear to indicate there is no way to get in without poking the indieweb people on IRC or via email.
A known consumer clears the client check and trips the next rule. Mine never gets that far. That’s not a reading of the source, it’s the live service behaving differently based only on the domain.
None of this is a knock on IndieLogin. It’s free, someone pays to run it, and an anti-abuse step simply never got automated — issue #20 says as much. But “free for anyone to use” and “requires a manual step that isn’t happening” are both true at once, and only the first one is documented.
The actual failure
One curl to /authorize would have caught this before a line of code was written. It never happened. The whole migration — design, implementation, publish, deploy — rested on an API description that was accurate about the protocol and silent about the policy, and nobody poked the real endpoint.
My assumption was, its working like indieauth.com, why would anyone want to maintain a list of allowed clients ???
That’s the AI-assisted failure mode worth naming, and it isn’t “the AI wrote bad code.” The code was fine. The tests passed. The commit message was better than mine usually are. It was confidently, fluently wrong about something it could have checked in ten seconds, and confident fluent output is exactly what stops you asking.
What caught it was me, in an incognito window, doing the thing a user does : testing
Docs describe the protocol. Deployments enforce the policy. Only one of them rejects you.
Back to IndieAuth, but better
We reverted, which restored a working system that still had the §5.4 hole. So the rest of the day went on fixing that properly instead of delegating around it.
Profile URL verification. When the returned me differs from what was typed, re-discover it and require the same authorization endpoint. An endpoint claiming rmendes.net now gets refused. Failure to re-discover fails closed. Verified against live discovery, not just stubs.
Discovery via microformats instead of pattern matching. The old code matched <link rel="x" href="y"> with a regex. Measured against real markup, it handled one shape out of five:
| markup | regex | mf2 |
|---|---|---|
relative href="/auth" |
/auth — unresolved |
resolved |
| single quotes | missed | found |
rel="me authorization_endpoint" |
missed | found |
<a rel=...> (spec-legal) |
missed | found |
None of those failed loudly. A missed rel silently routes someone running their own authorization server to the third-party fallback instead — the opposite of what this plugin is for. microformats-parser was already in the dependency tree. It just wasn’t being used, so we started using it !
Server metadata discovery. Servers publishing rel="indieauth-metadata" now get that document preferred over the rels. It’s also the only place an issuer is published, so the iss on the authorization response can finally be checked. The spec mandates simple string comparison there, not URL normalisation — a trailing-slash difference is a mismatch, which is the opposite of what you’d assume. Worth checking before you ship it, as we’d just learned.
Amusingly, this site served a metadata document all along and never advertised it. One <link> in the theme fixed that.
The plugin went from 3 tests to 32, and npm test runs for the first time.
What I’m taking from it
The failed migration was worth more than a clean one would have been. It forced an articulation of why delegating would have been safer, which turned out to be the exact argument for doing the verification properly in-house.
But the durable lesson is about how I work now. An AI can hold more of the IndieAuth spec in its head than I can, write better commit messages than I do, and produce a migration that is coherent, tested, well-documented and completely unshippable — because it never touched the thing it was integrating with.
If you have implemented IndieAuth for commenting on your blog, what was your approach ?
AI: Text AI-generated · Code AI-generated · Claude (Claude Code)
Claude drafted this post and wrote the plugin changes it describes. I directed the work, caught the production regression in an incognito window, pushed back on its conclusions until it tested them, and reviewed the text before publishing.
Comments
Sign in with your website to comment:
Loading comments...
No comments yet. Be the first to share your thoughts!