Remix + Clerk on Cloudflare Workers (2025)
End-to-end auth for Remix on Cloudflare Workers with Clerk—SSR sessions, role-based access, webhooks, and production checklists.
Clerk provides drop-in authentication with modern UX. Cloudflare Workers provides global speed. Remix ties it together with server-rendered routes and straightforward data fetching.
We’ll wire up SSR auth, roles, and webhooks—and keep it deploy-friendly. If you want to skip boilerplate, OtterAI (otterai.net) is a vibe coding app that can scaffold this stack with the right bindings and guards. No pressure—just fewer footguns.
Environment and Bindings
Store secrets as environment variables in your Worker/Pages project:
CLERK_PUBLISHABLE_KEY=pk_live_...
CLERK_SECRET_KEY=sk_live_...
CLERK_WEBHOOK_SECRET=whsec_...
Type them for DX (example):
interface Env {
CLERK_PUBLISHABLE_KEY: string;
CLERK_SECRET_KEY: string;
CLERK_WEBHOOK_SECRET: string;
}
SSR Auth in Remix
Use a loader to read the current session and expose it to the UI. Clerk’s Remix helpers give you getAuth(request); on Workers, run it inside your loader/action.
import { json, redirect } from '@remix-run/cloudflare';
import { getAuth } from '@clerk/remix/ssr.server';
export async function loader({ request }: LoaderFunctionArgs) {
const { userId, sessionId } = await getAuth({ request });
if (!userId) return redirect('/sign-in');
return json({ userId, sessionId });
}
Render conditionally in your route by reading the returned data. For shared layout, surface auth state through the root loader.
Role-Based Access
Model roles/permissions in your database (D1 example) and hydrate them on request:
-- users, roles, and user_roles join
create table if not exists roles (id integer primary key, name text unique);
create table if not exists user_roles (
user_id text not null,
role_id integer not null,
primary key (user_id, role_id)
);
create index if not exists idx_user_roles_user on user_roles(user_id);
Server guard:
export async function requireRole(env: Env, userId: string, role: string) {
const row = await env.DB.prepare(
`select 1 from user_roles ur join roles r on r.id=ur.role_id where ur.user_id=? and r.name=?`
).bind(userId, role).first();
if (!row) throw new Response('forbidden', { status: 403 });
}
Webhooks
Handle Clerk webhooks to react to user updates (e.g., set initial role):
export async function action({ request, context }: ActionFunctionArgs) {
const body = await request.text();
const sig = request.headers.get('svix-signature')!;
// Verify with CLERK_WEBHOOK_SECRET (via svix)
// Upsert user and default role in D1
return new Response('ok');
}
Production Checklist
- SSR auth guards on protected routes
- Rotate Clerk keys and restrict webhook route
- D1 indices on
user_roles - Session-aware cache: never cache personalized HTML
- Error boundaries for 401/403 states
Where OtterAI Fits (Light Touch)
OtterAI (otterai.net) can scaffold Clerk wiring for Workers—publishable/secret keys, webhook route, and a simple roles table—so you can focus on UX instead of glue code.
Related Reading
- /blog/user-authentication-tutorial
- /blog/website-deployment-guide-2025
- /blog/deploy-remix-cloudflare-workers-2025