The Plan That Did Not Work
The first attempt at a comment system for this blog used Hastodon, a Haskell client library for the Mastodon API. The idea was straightforward: readers with Mastodon accounts authenticate, post a comment, and the server posts it as a toot reply on their behalf. The blog has a Mastodon presence at mathstodon.xyz/@xameer, so the integration felt natural.
It was not natural. Here is why.
What Hastodon Actually Does
Hastodon is a Mastodon API client library, last updated in 2018 (0.6.0 on Hackage). It wraps the Mastodon REST API and provides functions for posting statuses, reading timelines, following accounts, and streaming. Its authentication model relies on the OAuth 2.0 password grant flow:
maybeClient <- mkHastodonClient clientId clientSecret email password "mastodon.social"You give it credentials, it gives you a bearer token via POST /oauth/token with grant_type=password. This is the Resource Owner
Password Credentials (ROPC) grant, and it is the core of why the approach
failed.
The Password Grant Problem
The OAuth 2.0 password grant requires the client application to receive the user’s plaintext username and password, then forward them to the authorization server. This is the opposite of what OAuth was designed for. It defeats the entire point of delegated authorization: the user trusts the client application with their credentials rather than the identity provider.
Mastodon’s own documentation now reads:
Mastodon has historically supported the Password Grant flow, however, usage is not recommended by the OAuth 2 Specification authors due to security issues, and has subsequently been removed from future versions of Mastodon.
The password grant was removed from Mastodon 4.x. mkHastodonClient in
Hastodon calls this removed endpoint. On any modern Mastodon instance, it
returns a 401 or 400 immediately. The library cannot be used for user
authentication on current Mastodon without modifying the authentication
module entirely.
Hastodon 0.6.0 depends on http-client, aeson, and conduit pinned to
versions from 2018. Getting it to build with a modern GHC requires
overriding multiple dependency bounds. The library is effectively
unmaintained — 34 stars, no releases, 7 open issues, zero pull requests.
What the Code Looked Like
The attempt produced three modules: ApiClient.hs, WarpHandler.hs, and
a TLS diagnostic. ApiClient hand-rolled the OAuth token request using
http-client directly rather than Hastodon’s mkHastodonClient, because
Hastodon’s internals were not composable with Warp’s request model. This
is telling: if you are rewriting the authentication layer of a library to
use it, the library is not helping you.
WarpHandler implemented a multi-step HTML form flow: login form → POST
credentials → check login → 2FA form → POST OTP → toot form → POST toot.
The app function pattern-matched on (requestMethod req, pathInfo req):
("POST", []) -> do
mCreds <- handleLoginPost req
case mCreds of
Just (uid, pass) -> do
success <- mastoCheckLogin manager uid pass
if success
then respond $ responseLBS status200 [] twoFAFormHtml
else respond $ responseLBS status200 [] loginFailHtml
Nothing ->
respond $ responseLBS status200 [] loginFormHtmlThe mastoCheckLogin function sent a POST to /oauth/token with
grant_type=password. On mathstodon.xyz (Mastodon 4.x), this returned
401 on every call. The 2FA form was never reached. The toot form was never
reached.
Development hours spent: approximately 12–15 hours across three sessions. The blocker was not discovered until the Mastodon documentation was read carefully. The library’s README does not mention the password grant deprecation.
Could It Have Been Made to Work?
Not without significant changes, and not cleanly for a blog commenting use case even if the auth had worked.
Auth problem: Modern Mastodon requires the authorization code flow. The user is redirected to the Mastodon instance login page, authenticates there, and is redirected back with a code. The client never sees the password. Implementing this in Warp requires a proper OAuth callback endpoint, session state between the redirect and the callback, and PKCE support for Mastodon 4.3+.
Use case problem: Even if auth worked, the comment flow would require the commenter to have a Mastodon account, be comfortable with OAuth redirects, and accept that their comment appears as a toot reply on a specific Mastodon account. This ties the comment system to Mastodon’s availability and the specific instance. It also means comments are stored as toots, not as structured data local to the blog.
Integration problem: Warp + Hastodon + session management + PKCE +
toot-as-comment mapping is a significant amount of code for a blog comment
form. The initAppState / IORef AppState pattern in WarpHandler.hs
was already growing to handle per-session instance selection — a problem
that does not exist in IndieAuth because the commenter’s identity URL is
instance-agnostic.
A workaround that could have worked: Skip mkHastodonClient entirely.
Register an OAuth app once, hardcode the client credentials, redirect the
user to /oauth/authorize, receive the code on /callback, exchange it
for a bearer token, and use the Hastodon API functions that take a token
directly via HastodonClient instance token. This bypasses the broken
authentication layer. The Hastodon API surface for posting a status is
fine — postStatus client "text" works. The cost is implementing the
entire authorization code flow manually, at which point Hastodon provides
only postStatus and timeline reads, which are both straightforward
http-client calls.
The Approach That Worked: IndieAuth
IndieAuth is a decentralized identity protocol
built on OAuth 2.0. The commenter provides a URL — their personal website,
any domain with a <link rel="authorization_endpoint"> in its HTML. The
server discovers the authorization endpoint from that URL, redirects the
commenter there, and receives a verified identity claim on callback.
The key property: the server never handles a password. The commenter
authenticates at their own identity provider. The blog comment system
receives only a verified me URL — proof that the commenter controls the
domain they claimed.
How it works
This blog has a commenting service built on a single-binary Rust microservice - rs-comment-api. It runs as a NixOS systemd service on node1, a QEMU VM inside my ThinkPad, and is exposed to the public internet via Tailscale Funnel. No VPS, no managed cloud, no accounts required to comment.
Anyone with a domain that supports IndieAuth can leave a comment. The service verifies identity by redirecting the commenter to their own authorization endpoint, then stores the verified comment in Turso libSQL and pushes a JSON file back to the blog repository via git2. The next CI pipeline rebuilds the static site with the comment included.
POST /comment receives a JSON body with the post slug, the commenter’s website URL, the comment text, and a honeypot field. The service scrapes the commenter’s website for a <link rel="authorization_endpoint"> tag, generates a random state token, stores the pending auth state in Turso, and returns a redirect URL for the browser to navigate to.
GET /auth/callback is the IndieAuth redirect target - publicly reachable via Tailscale Funnel. It receives the authorization code and state, verifies the code against the commenter’s endpoint, fetches their display name, inserts the comment into Turso, and pushes comments/{slug}.json to the blog repository. The git push triggers a GitLab CI pipeline which rebuilds the site.
GET /health returns {"status":"ok"} and is used for uptime checks.
Proof of Concept
Unless my VM is down, this curl command against localhost should return a redirect URL:
curl -v -X POST http://127.0.0.1:3000/comment \
-H 'Content-Type: application/json' \
-d '{
"slug": "2026-03-18-rust-python-interops",
"author_url": "https://xameer.gitlab.io",
"body": "test comment",
"trap": ""
}'The trap field is a honeypot - any non-empty value causes the server to silently discard the request.
The response on success is a JSON object with a redirect key pointing to the commenter’s IndieAuth provider:
{"redirect":"https://indieauth.com/auth?response_type=code&..."}After the commenter authenticates at their provider and is redirected back, the comment appears in the next site build.
Deployment
The service is deployed as a NixOS systemd unit:
systemd.services.comment-api = {
wantedBy = [ "multi-user.target" ];
after = [ "network.target" "agenix.service" ];
environment = {
PORT = "3000";
RUST_LOG = "comment_api=info,warp=warn";
};
serviceConfig = {
ExecStart = "${comment-api-pkg}/bin/comment-api";
EnvironmentFile = config.age.secrets."comment-api-env".path;
Restart = "on-failure";
RestartSec = 5;
User = "comment-api";
StateDirectory = "comment-api";
};
};The environment file (managed by agenix) contains TURSO_URL, TURSO_AUTH_TOKEN, BLOG_REPO, and BASE_URL.
Tailscale Funnel exposes port 3000 publicly:
tailscale funnel --bg --https=443 3000The public URL is https://node1-2.tail1a8ca.ts.net. The IndieAuth /auth/callback endpoint must be publicly reachable - this is why Tailscale Funnel is used rather than a Cloudflare Zero Trust tunnel with access controls, which would block the callback redirect.
Tailscale Funnel availability depends on the ThinkPad being on and connected. There is no queue - if the host is offline when someone submits a comment, the request fails silently. For always-on availability the service would need to move to a VPS or a Raspberry Pi. The Turso database is independent of host uptime.
Data Flow
browser POST /comment → discover IndieAuth endpoint (scraper) → save pending state (Turso) → return redirect URL browser follows redirect → commenter’s IdP → commenter authenticates → IdP redirects to /auth/callback GET /auth/callback → verify code (commenter’s endpoint) → fetch display name → insert comment (Turso) → git push comments/{slug}.json (git2) → CI rebuild → new static site
Leave a comment
Comments are verified via IndieAuth. You will be redirected to authenticate before your comment is published.