Taking the keys out of Rill
Replacing a plaintext MCP secret with OAuth, publishing the SDK, and moving a live site off Sanity.
July 21, 2026 · 6 min read
The thing that bothered me
Rill's MCP server started life as a local stdio process. Claude Code spawned it, and it read a rill_sk_ key out of an environment variable in .mcp.json.
That worked, and it was the only thing that could work. stdio has no browser and no redirect URI, so the MCP spec has no OAuth story for it — an environment variable is genuinely the only option. But look at what you end up holding: a write-capable credential, sitting in a config file in plaintext, that never expires, one per machine, revocable only by killing the key for everything at once.
Asking someone to paste a long-lived secret into a dotfile is asking them to trust you more than the design deserves.
What replaced it
The server is now remote, at rill.page/api/mcp, speaking streamable HTTP with OAuth:
claude mcp add --transport http rill https://rill.page/api/mcpThere is no API key anywhere in that. The client runs the OAuth flow itself and holds a token that expires, refreshes, and can be revoked per device. As a side effect it also means Rill works from claude.ai and from a phone, not just from the one laptop where the key happened to live.
The decision underneath it: Rill does not issue tokens
The tempting thing when you add OAuth to a product is to build the authorization server. We didn't. Supabase Auth ships a full OAuth 2.1 authorization server — /authorize, /token, dynamic client registration, discovery — and the project was already there. Rill is only a resource server. It verifies tokens and never mints them.
That keeps the surface honest. Token verification is getClaims() against the project's JWKS, cached in process: signature and expiry checked locally, no introspection call, no database lookup. Two checks carry the weight:
audmust beauthenticated. Every token in the project is signed with the same key, so a token minted for a different resource would verify here perfectly well without this check. It is the kind of hole that looks like nothing and is everything.Membership comes from
workspace_members, never from the token.
The tools run with the service role and therefore bypass RLS, which means resolveCaller is the tenant boundary — every query a tool makes has to be filtered by a workspace id that came out of it. With one workspace the choice is implicit; with several, the tool refuses and names the options rather than guessing. A note landing silently in the wrong workspace is the specific failure that design is against.
Worth doing later: run the queries under the user's own token so the database enforces tenancy too, rather than only the code.
The handshake configures itself
Client POSTs with no token
Rill returns 401 with
WWW-Authenticate: … resource_metadata="…"Client fetches
/.well-known/oauth-protected-resource, which names Supabase as the authorization serverClient registers itself there dynamically, runs PKCE, lands the browser on
/oauth/consentUser approves; client retries with a token
Nothing in steps 3–5 is configured by hand on either side. One response header is the entire bootstrap.
Two things worth writing down because they cost time. supabase config push silently ignores the [auth.oauth_server] block — it reports "Remote Auth config is up to date" while having changed nothing. The dashboard is what actually turns it on; verify against the live discovery endpoint, not the CLI's own account of itself. And client names on the consent screen are attacker-controlled, because registration is open. The name identifies the client; it does not vouch for it.
What the server gave up, and why nothing was lost
Running on Vercel means the server cannot open a file path or shell out to git. The stdio version did both.
Nothing is actually lost, because the client already has that access, under permissions its user controls. Provenance: Claude Code runs git rev-parse HEAD and passes the result as sources — that is how this note is linked to its commit. Images: Claude Code reads the file and sends base64.
From the author's side the conversation is identical. The difference is that Rill no longer asks for a foothold on anyone's disk.
@rillpage/sdk is on npm
The SDK is published, which removes the other awkward dependency: consumers used to vendor the source, because a file: dependency resolves locally and dies in CI where the monorepo isn't sitting next to the consumer repo.
The scope is @rillpage rather than @rill because npm orgs share a namespace with package names, and rill is taken by a business intelligence tool. Scoped rather than a bare rill-sdk on purpose: a scope reserves the namespace, so nobody else can publish @rillpage/anything. rill-mcp would have been free for anyone to claim and would have looked official.
Two packaging notes. The package ships compiled dist/ output with type definitions alongside, unlike the internal workspace packages that ship raw TypeScript and lean on transpilePackages — an installed package has to give consumers types without asking them to compile someone else's source. And supporting Next 15 and 16 at once required a small dishonesty: revalidateTag is typed (tag) in 15 and (tag, profile) in 16, where the profile is required, so no single call site typechecks against both. The runtimes are laxer than the types, so the profile goes through a locally-widened signature — correct on 16, harmless on 15, and the cast exists only to stop whichever version is installed from rejecting it at compile time.
Long-term the right answer for publishing is Trusted Publishing from GitHub Actions, verified by OIDC, with no token existing anywhere. Not set up yet.
Rill is now running a real site
tobiaseichenwald.com is off Sanity and on Rill. Install the SDK, create a client, then rill.posts.list() on the index, rill.posts.get(slug) plus RillArticle on the post page, postMetadata for SEO, postsSitemap for the sitemap, and a webhook so publishing shows up immediately. Studio, schema types, GROQ queries, the Portable Text renderer and the old webhook route were all deleted — which cleared both critical CVEs the site was carrying.
The whole game in a blog migration is URL preservation. All five /blog/<slug> URLs still resolve, because the migration passed each original slug explicitly rather than letting Rill derive one from the title. That is why rill_write_note takes a slug argument at all.
The lesson from the first attempt is not about slugs specifically. Frontmatter wasn't JSON-parsed, so escaped em-dashes ended up as literals in the body, and the slugs were lost because the tool had no slug parameter yet. Both were fixed quickly. The shape of the mistake was verifying that the API returned 201 instead of verifying the rendered output. A migration is not done when the writes succeed; it is done when the pages look right and the old links still work.
The theme, in case it isn't obvious
Three changes, one direction: a secret in a config file became a revocable token, a vendored source tree became a published package, and a CMS with two critical CVEs became a dependency we control. Each one is less to hand out and less to keep track of.