Summary
The customer surface ships on account.klappy.dev via Supabase + Lovable + Stripe per klappy://docs/oddkit/sales/unified-account-launch-plan. That surface issues two kinds of credentials customers use against MCP servers: short-lived JWTs (for live web/AI sessions where the user signs in) and long-lived PATs (for headless MCP clients like Cursor, Claude Code, ChatGPT that need a pasteable token). Every klappy.dev MCP server needs to validate either credential at the Worker edge before serving the request. This handoff specifies that middleware as a single file shared across server repos by copy or by lifting to a small published package later.
Today's behavior is intentionally minimal. Authenticated callers see the same surface as anonymous callers do — there is no tier-gated tool path yet, no rate-limit differential, no entitlement-aware response. The middleware exists tonight to terminate the anonymous-by-default era and to give future tier features a place to hang off. The reason this is Build = minimal and not deferred entirely: a single middleware in three repos is cheap; backfilling auth to three repos after tier features exist is not.
The companion canon for the substrate decision is klappy://canon/constraints/borrow-evaluation-before-implementation and the 6B Table B in this doc satisfies it. The companion plan for the customer surface that issues these tokens is klappy://docs/oddkit/sales/unified-account-launch-plan — read it first.
Scope
In scope:
- A
validateBearerToken(request, env)middleware function that returns{ user: { id, email, entitlements } } | null. - JWT validation against the Supabase JWKS endpoint.
- PAT validation against the Supabase
patstable via Supabase service-role key. - JWKS caching at the Worker edge (15-minute TTL).
- Wiring the middleware into
klappy/oddkit's existing tool router so every MCP request flows through it. - Replicating the same middleware in
klappy/aquifer-mcp.
Out of scope:
- OAuth-MCP server endpoints (
/.well-known/oauth-protected-resource,/.well-known/oauth-authorization-server, dynamic client registration). Deferred per the Bide row below. - Tier-gated tool behavior. The middleware annotates the request with claims; no tool reads them today.
- Rate-limit differentiation. Same limits for anonymous and authenticated for v1.
- TruthKit Worker integration. Lifts when TruthKit ships its first MCP endpoint.
6B EvaluationMCP-side authentication (Table B)
This satisfies klappy://canon/constraints/borrow-evaluation-before-implementation for the MCP-server middleware implementation task.
Candidate substrates inventoried: jose@^5 (JWT signature verification, JWKS support), @cloudflare/agents (Cloudflare's MCP server framework with built-in auth helpers), @modelcontextprotocol/sdk (TS SDK from MCP authors, OAuth helpers emerging), jsonwebtoken (Node-classic, not CF Workers-friendly).
| Step | Verdict | Justification |
|---|---|---|
| Borrow | applied | jose@^5 for JWT signature verification against Supabase's JWKS (https://<project>.supabase.co/auth/v1/.well-known/jwks.json); @supabase/supabase-js (service-role client) for the PAT hash lookup. @cloudflare/agents evaluated; its auth helpers fit the JWKS path but bring framework conventions (McpAgent, durable-object-backed sessions) that the oddkit Worker does not currently use. Adopting just the auth helpers without the framework is awkward; jose is the leaner Borrow. |
| Bend | applied | One shared validateBearerToken(request, env) function across servers. Reads Authorization: Bearer <token> header. Token-shape sniff: if it starts with oddkit_ → PAT path (hash + lookup); else → JWT path (signature-verify against JWKS). Returns a uniform { user, entitlements } shape regardless of which path validated. |
| Break | none-yet | Will surface when wired: (a) JWKS caching behavior at the Worker edge under burst load; (b) PAT-hash lookup latency on cold Worker invocations; (c) pgcrypto.crypt() comparison cost at scale. Each becomes a real Break only when measured under production load, not preemptively. |
| Beget | n/a | No third party building this slice. |
| Bide | waiting | OAuth-MCP-client flow (per the MCP Authorization spec, 2025) is the right future UX — "Click Connect → sign in once → done," no API-key paste. Tripwire: when 2+ major MCP clients (Claude.ai, Cursor, Continue, Cline, ChatGPT MCP) ship interactive OAuth-MCP UX that visibly beats paste-a-key in real user testing. Fallback during wait: PAT bearer tokens — current universal SaaS-API pattern. Inspection criteria when tripwire fires: vision-fit (does the client OAuth flow match Klappy's user-provisioning intent?), opinionated-stack imposition (does the spec require a specific OAuth dialect Klappy doesn't already speak via Supabase?), improper authority (does the client dictate how Klappy issues or revokes credentials?). |
| Build | minimal | The validateBearerToken() middleware itself (~30 lines TypeScript) plus JWKS-cache helper (~10 lines) plus PAT hash-lookup helper (~15 lines). Tied to gaps in Borrow: Supabase issues JWTs but doesn't validate-them-at-the-edge-of-a-Cloudflare-Worker; Supabase Auth doesn't natively mint long-lived API keys outside the JWT flow; oddkit's Worker does not use the @cloudflare/agents framework so the framework's auth helpers aren't a clean fit. |
Reversibility: forward = high (middleware is one file shared across servers; swappable for
@modelcontextprotocol/sdkhelpers when they mature, or for@cloudflare/agentsif the framework is adopted broadly); backward = high (if Supabase is ever replaced as the IdP, only the JWKS URL and the claim names change; the PAT path is fully owned).
Build specification
File location
Each server repo gets its own copy. Lift to a shared package only after the third repo proves the pattern is stable.
klappy/oddkit:workers/src/auth.ts— new file.klappy/aquifer-mcp:workers/src/auth.ts— new file (copy-paste with adjusted imports for v1).
Dependencies
"dependencies": {
"jose": "^5.0.0",
"@supabase/supabase-js": "^2.0.0"
}
@supabase/supabase-js is already a dependency in oddkit; verify before adding.
Environment variables (Worker bindings)
| Binding | Value | Used for |
|---|---|---|
SUPABASE_URL | https://<project>.supabase.co | JWKS fetch + PG client |
SUPABASE_SERVICE_ROLE_KEY | service-role key | PAT hash lookup (server-side only — never expose) |
SUPABASE_JWT_AUDIENCE | authenticated | JWT aud claim verification |
validateBearerToken contract
type AuthContext = {
user: { id: string; email: string };
entitlements: string[];
via: 'jwt' | 'pat';
};
async function validateBearerToken(
request: Request,
env: Env,
): Promise<AuthContext | null>
Returns null for missing header, malformed header, expired JWT, revoked PAT, or any verification failure. Never throws to the caller; logs internally for observability.
Sketch
import { jwtVerify, createRemoteJWKSet } from 'jose';
import { createClient } from '@supabase/supabase-js';
let cachedJWKS: ReturnType<typeof createRemoteJWKSet> | null = null;
let jwksCachedAt = 0;
const JWKS_TTL_MS = 15 * 60 * 1000;
function getJWKS(env: Env) {
const now = Date.now();
if (!cachedJWKS || now - jwksCachedAt > JWKS_TTL_MS) {
cachedJWKS = createRemoteJWKSet(
new URL(`${env.SUPABASE_URL}/auth/v1/.well-known/jwks.json`),
);
jwksCachedAt = now;
}
return cachedJWKS;
}
export async function validateBearerToken(
request: Request,
env: Env,
): Promise<AuthContext | null> {
const header = request.headers.get('Authorization');
if (!header?.startsWith('Bearer ')) return null;
const token = header.slice(7).trim();
if (!token) return null;
// PAT path
if (token.startsWith('oddkit_')) {
const supabase = createClient(
env.SUPABASE_URL,
env.SUPABASE_SERVICE_ROLE_KEY,
);
const { data, error } = await supabase
.rpc('validate_pat', { token }) // RPC does pgcrypto.crypt() comparison server-side
.single();
if (error || !data) return null;
return {
user: { id: data.user_id, email: data.email },
entitlements: data.entitlements ?? [],
via: 'pat',
};
}
// JWT path
try {
const { payload } = await jwtVerify(token, getJWKS(env), {
audience: env.SUPABASE_JWT_AUDIENCE,
});
return {
user: { id: payload.sub as string, email: payload.email as string },
entitlements: (payload.entitlements as string[]) ?? [],
via: 'jwt',
};
} catch {
return null;
}
}
Wiring into the existing request path
In klappy/oddkit:workers/src/index.ts, the existing MCP request handler gets a leading middleware call:
const auth = await validateBearerToken(request, env);
// auth is null for anonymous; that is OK for v1 — every tool path stays open.
// Attach to the request context for any future tool that wants to read it.
const ctx = { ...existingCtx, auth };
No tool currently branches on auth. The middleware exists today to terminate anonymous-by-default and to give future tier-gated tools a place to hang off.
Supabase RPC for PAT validation
In klappy/klappy.dev (or wherever the Supabase migrations live for the customer surface), create:
CREATE OR REPLACE FUNCTION validate_pat(token TEXT)
RETURNS TABLE(user_id UUID, email TEXT, entitlements JSONB)
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
RETURN QUERY
SELECT p.user_id, u.email, u.raw_app_meta_data->'entitlements'
FROM pats p
JOIN auth.users u ON u.id = p.user_id
WHERE p.revoked_at IS NULL
AND p.hash = crypt(token, p.hash);
END;
$$;
The crypt(token, p.hash) comparison is constant-time within pgcrypto.
Acceptance criteria
- A request to any
klappy/oddkitMCP endpoint with noAuthorizationheader succeeds (anonymous path unchanged). - A request with
Authorization: Bearer <valid_supabase_jwt>succeeds and the handler's request context containsauth.user.idmatching the JWT subject. - A request with
Authorization: Bearer <valid_pat>(where the PAT was issued via/functions/v1/create-patand stored inpats) succeeds and the context containsauth.via='pat'. - A request with an expired JWT, malformed token, or revoked PAT succeeds anonymously (auth context is null, request still serves).
- JWKS is fetched at most once per 15 minutes per Worker instance under steady load.
- The same middleware deployed in
klappy/aquifer-mcpexhibits the same behavior. - No tool reads or branches on
authyet — verify by code search thatauthis attached but unused in tool handlers. (Future tier-gated tools opt in explicitly.)
Out of scopewhat this handoff forbids
- Tier-gated tool behavior. Tools must not start branching on
auth.entitlementsin this slice. That comes later, per-tool, when features ship. - OAuth-MCP discovery endpoints. Deferred per the Bide. Add when the tripwire fires.
- Rate-limit differentiation. Same limits for anonymous and authenticated. The "founding-rate locked-in" and "TruthKit waitlist priority" benefits are operator-promised, not middleware-enforced.
- Anthropic key consumption. The
anthropic_keystable is populated by the customer surface but read by nothing in this slice.
Release-validation gate
This is a code-shipping slice across two Worker repos. The release-validation-gate (klappy://canon/constraints/release-validation-gate) applies:
- Cursor Bugbot must reach
completedon both PRs (klappy/oddkitandklappy/aquifer-mcp). - For each repo, dispatch an independent Sonnet 4.6 read-only validator session via Managed Agents before promotion merges.
- Same-session smoke does NOT satisfy the gate.
Branch → PR → squash merge. Never direct commits to main or prod.
See also
klappy://docs/oddkit/sales/unified-account-launch-plan— the customer surface that issues the credentials this middleware validatesklappy://canon/constraints/borrow-evaluation-before-implementation— 6B canonklappy://canon/constraints/release-validation-gate— release gateklappy://canon/principles/vodka-architecture— thin stateless servers, stateful canonhttps://supabase.com/docs/guides/auth/jwts— Supabase JWT structure and JWKShttps://github.com/panva/jose—joselibrary docs- MCP Authorization spec (2025) — the future OAuth-MCP path waiting on the Bide tripwire