skills/workos/SKILL.md
--- name: workos description: Wire WorkOS AuthKit into a TanStack Start app on Cloudflare Workers, install, env config, sign-in + callback routes, sealed-session cookie, server auth helper, Drizzle shadow user table, organisations + Admin Portal hooks, optional SAML SSO. Use when user wants to add WorkOS auth, AuthKit, hosted login, organisations, B2B login, SSO, or social login to a TanStack Start + Drizzle + D1 app and does not need to own the user table. category: auth argument-hint: [install
npx skillsauth add RonanCodes/ronan-skills skills/workosInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Wire WorkOS AuthKit into a TanStack Start + Drizzle + D1 app on Cloudflare Workers. Hosted sign-in UI, sealed-session cookie, organisations + Admin Portal for non-engineer partners, SSO when an enterprise merchant asks.
This is the canonical auth pick for the user's stack as of 2026-04-30. For the inverse case (own the user table, EU residency mandate, fully custom flows), use /ro:better-auth instead. Comparison: llm-wiki-research/wiki/comparisons/auth-clerk-vs-better-auth.md.
/ro:workos install # initial wiring (env + sign-in + callback + middleware)
/ro:workos install --social github,google # + GitHub + Google providers
/ro:workos add-organizations # multi-tenant orgs + switcher
/ro:workos add-webhook # /api/webhooks/workos with signature verification
/ro:workos add-sso # SAML SSO, $125/mo per connection at time of writing
/ro:workos open-portal # open the WorkOS dashboard for this app
/ro:new-tanstack-app or /ro:migrate-to-tanstack)src/db/schema.ts, wrangler.toml with [[d1_databases]])pnpm add @workos-inc/authkit-session @workos-inc/node
The authkit-session package is the framework-agnostic toolkit (works in Workers). @workos-inc/node is the server SDK for management API calls (webhooks, user lookup, org membership).
WORKOS_COOKIE_PASSWORD (per-app)openssl rand -base64 32
Must be at least 32 characters. WorkOS uses it to seal the session cookie (iron-webcrypto + AES-256).
# .dev.vars
WORKOS_CLIENT_ID=client_01ABC
WORKOS_API_KEY=sk_test_...
WORKOS_REDIRECT_URI=http://localhost:3000/api/auth/callback
WORKOS_COOKIE_PASSWORD=<openssl output>
Production secrets:
wrangler secret put WORKOS_CLIENT_ID
wrangler secret put WORKOS_API_KEY
wrangler secret put WORKOS_REDIRECT_URI # = https://your-app.com/api/auth/callback
wrangler secret put WORKOS_COOKIE_PASSWORD
WORKOS_CLIENT_ID and the public sign-in domain come from your Application page in the WorkOS dashboard. WORKOS_API_KEY is per-environment (separate test + live keys).
src/lib/auth.tsThe Workers runtime has no process.env, so configure programmatically from the request env binding.
import { configure, AuthService } from '@workos-inc/authkit-session';
let configured = false;
export function getAuth(env: Env) {
if (!configured) {
configure({
clientId: env.WORKOS_CLIENT_ID,
apiKey: env.WORKOS_API_KEY,
redirectUri: env.WORKOS_REDIRECT_URI,
cookiePassword: env.WORKOS_COOKIE_PASSWORD,
cookieName: 'wos-session',
cookieMaxAge: 60 * 60 * 24 * 30,
cookieSameSite: 'lax',
});
configured = true;
}
return new AuthService();
}
Add the env shape to your Env type, usually in worker-configuration.d.ts (regenerated by wrangler types).
src/routes/api/auth/sign-in.tsimport { createServerFileRoute } from '@tanstack/react-start/server';
import { getAuth } from '@/lib/auth';
export const ServerRoute = createServerFileRoute('/api/auth/sign-in').methods({
GET: async ({ request, context }) => {
const auth = getAuth(context.cloudflare.env);
const { url, headers } = await auth.createSignIn(undefined, {
returnPathname: new URL(request.url).searchParams.get('returnTo') ?? '/dashboard',
});
const res = new Response(null, { status: 302, headers: { Location: url } });
for (const [k, v] of Object.entries(headers)) {
const values = Array.isArray(v) ? v : [v];
for (const val of values) res.headers.append(k, val);
}
return res;
},
});
src/routes/api/auth/callback.tsimport { createServerFileRoute } from '@tanstack/react-start/server';
import { getAuth } from '@/lib/auth';
export const ServerRoute = createServerFileRoute('/api/auth/callback').methods({
GET: async ({ request, context }) => {
const auth = getAuth(context.cloudflare.env);
const url = new URL(request.url);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state') ?? undefined;
if (!code) return new Response('Missing code', { status: 400 });
try {
const result = await auth.handleCallback(request, new Response(), { code, state });
const redirect = result.returnPathname ?? '/dashboard';
const res = new Response(null, { status: 302, headers: { Location: redirect } });
const setCookie = result.headers?.['Set-Cookie'] ?? result.headers?.['set-cookie'];
if (setCookie) {
for (const v of Array.isArray(setCookie) ? setCookie : [setCookie]) {
res.headers.append('Set-Cookie', v);
}
}
return res;
} catch {
return Response.redirect('/sign-in?error=auth_failed', 302);
}
},
});
src/routes/api/auth/sign-out.tsimport { createServerFileRoute } from '@tanstack/react-start/server';
import { getAuth } from '@/lib/auth';
export const ServerRoute = createServerFileRoute('/api/auth/sign-out').methods({
POST: async ({ context }) => {
const auth = getAuth(context.cloudflare.env);
const { url, headers } = await auth.createSignOut();
const res = new Response(null, { status: 302, headers: { Location: url } });
for (const [k, v] of Object.entries(headers)) {
const values = Array.isArray(v) ? v : [v];
for (const val of values) res.headers.append(k, val);
}
return res;
},
});
src/lib/auth-server.tsimport { createServerFn } from '@tanstack/react-start';
import { getAuth } from '@/lib/auth';
import { getEvent } from '@tanstack/react-start/server';
export const requireSession = createServerFn({ method: 'GET' }).handler(async () => {
const event = getEvent();
const auth = getAuth(event.context.cloudflare.env);
const { auth: session } = await auth.withAuth(event.request);
if (!session.user) throw new Response('Unauthorized', { status: 401 });
return {
userId: session.user.id,
email: session.user.email,
organizationId: session.organizationId,
roles: session.roles ?? [],
};
});
withAuth returns auth.user, auth.sessionId, auth.organizationId, auth.roles, auth.permissions, auth.entitlements. If tokens were refreshed, also persist refreshedSessionData via auth.saveSession(...) and append the returned cookies to your response, otherwise you will hit an infinite refresh loop on the next request.
No client SDK needed. Just link to the server route.
// src/components/sign-in-button.tsx
export function SignInButton() {
return <a href="/api/auth/sign-in?returnTo=/dashboard">Sign in</a>;
}
User data flows from requireSession server function into the route loader, then to the page.
WorkOS handles social providers in the dashboard, no app code change. Open the Application page, enable GitHub or Google under "Authentication providers", paste the OAuth client ID + secret you got from each provider's developer console.
Callback URL to register on the provider side: https://api.workos.com/sso/oauth/google/<client-id>/launch. The dashboard shows the exact value.
WorkOS organisations are free (included to 1M MAU). Each Organization has Members and Roles. Switch the active org by passing organizationId to createSignIn or by calling the org-switch helper:
// src/routes/api/auth/switch-org.ts
import { createServerFileRoute } from '@tanstack/react-start/server';
import { getAuth } from '@/lib/auth';
export const ServerRoute = createServerFileRoute('/api/auth/switch-org').methods({
POST: async ({ request, context }) => {
const form = await request.formData();
const organizationId = form.get('organizationId') as string;
const auth = getAuth(context.cloudflare.env);
const { url, headers } = await auth.createSignIn(undefined, {
organizationId,
returnPathname: '/dashboard',
});
const res = new Response(null, { status: 302, headers: { Location: url } });
for (const [k, v] of Object.entries(headers)) {
const values = Array.isArray(v) ? v : [v];
for (const val of values) res.headers.append(k, val);
}
return res;
},
});
The session cookie is replaced with one scoped to the new org. Roles + permissions in auth.roles are now org-scoped.
WorkOS pushes events on user.deleted, user.updated, organization-membership changes. You need a shadow users row in your D1 to join app data against.
// src/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: text('id').primaryKey(), // workos user_01...
email: text('email').notNull(),
firstName: text('first_name'),
lastName: text('last_name'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()),
deletedAt: integer('deleted_at', { mode: 'timestamp' }),
});
Foreign keys from your domain tables point at users.id (the WorkOS user ID, not an internal one).
src/routes/api/webhooks/workos.tsimport { createServerFileRoute } from '@tanstack/react-start/server';
import WorkOS from '@workos-inc/node';
import { db } from '@/db';
import { users } from '@/db/schema';
import { eq } from 'drizzle-orm';
export const ServerRoute = createServerFileRoute('/api/webhooks/workos').methods({
POST: async ({ request, context }) => {
const env = context.cloudflare.env;
const workos = new WorkOS(env.WORKOS_API_KEY);
const sig = request.headers.get('workos-signature');
if (!sig) return new Response('Missing signature', { status: 400 });
const body = await request.text();
let event;
try {
event = await workos.webhooks.constructEvent({
payload: JSON.parse(body),
sigHeader: sig,
secret: env.WORKOS_WEBHOOK_SECRET,
});
} catch {
return new Response('Bad signature', { status: 400 });
}
switch (event.event) {
case 'user.created':
case 'user.updated':
await db.insert(users).values({
id: event.data.id,
email: event.data.email,
firstName: event.data.firstName,
lastName: event.data.lastName,
}).onConflictDoUpdate({
target: users.id,
set: {
email: event.data.email,
firstName: event.data.firstName,
lastName: event.data.lastName,
updatedAt: new Date(),
},
});
break;
case 'user.deleted':
await db.update(users).set({ deletedAt: new Date() }).where(eq(users.id, event.data.id));
break;
}
return new Response('ok');
},
});
Add WORKOS_WEBHOOK_SECRET (separate from WORKOS_API_KEY) as a wrangler secret. Create the webhook endpoint in WorkOS dashboard, copy the signing secret, point it at https://your-app.com/api/webhooks/workos.
The shadow row is the tradeoff vs Better Auth. You do not own user attributes (email change happens in WorkOS, syncs to you). Custom fields go in a separate table keyed by users.id.
Enterprise merchants asking for SAML / OIDC SSO is the moment WorkOS earns its keep. Each connection is $125/mo at the time of writing (verify on workos.com/pricing). Wiring is one config flip:
Members of an SSO-enabled org get bounced through the IdP automatically.
WorkOS Admin Portal lets a non-engineer partner manage users, orgs, and SSO connections without touching code. Generate a one-time link from the server SDK:
import WorkOS from '@workos-inc/node';
const workos = new WorkOS(env.WORKOS_API_KEY);
const { link } = await workos.portal.generateLink({
organization: 'org_01ABC',
intent: 'sso', // or 'dsync', 'audit_logs', 'log_streams'
});
// share `link` with the partner, expires in 5 minutes
For full WorkOS dashboard access (your own admin work), invite the partner as a Team Member in the Organization Settings page of the WorkOS dashboard.
| Var | Where | Source |
|---|---|---|
| WORKOS_CLIENT_ID | .dev.vars + wrangler secret | WorkOS dashboard, Application page |
| WORKOS_API_KEY | .dev.vars + wrangler secret | WorkOS dashboard, API Keys (separate test + live) |
| WORKOS_REDIRECT_URI | .dev.vars + wrangler secret | dev: http://localhost:3000/api/auth/callback, prod: https://app.com/api/auth/callback |
| WORKOS_COOKIE_PASSWORD | .dev.vars + wrangler secret | openssl rand -base64 32 |
| WORKOS_WEBHOOK_SECRET | wrangler secret (only after add-webhook) | WorkOS dashboard, webhook endpoint detail |
AuthKit user PII is stored in the US by default with Data Privacy Framework transfers. Mandated EU residency is Enterprise plan, custom contract. If a Dutch merchant in your customer base names this as a hard requirement, your two options are:
/ro:better-auth (your DB, your region).For the user's current Simplicity x Taskforce partnership the team has decided residency is a tiebreaker, not a hard constraint. Document the trigger conditions in the project's auth-strategy.md so the flip is a known move.
If you flip away from WorkOS to a self-hosted setup, the path is:
users.list API to export every user (paginated). Save email + WorkOS ID + first/last name + email-verified flag.workos_user_id to the new internal ID in your domain tables.Plan one engineer-week and a week of soft migration window. Not free, not catastrophic.
WORKOS_API_KEY or WORKOS_COOKIE_PASSWORD in ~/.claude/.env. Per-app secrets only. A leaked cookie password lets anyone forge sessions for that one app.WORKOS_REDIRECT_URI must match exactly what is registered in the WorkOS Application's Redirect URIs list. Mismatches return opaque 400s.Set-Cookie append loop in the callback handler. WorkOS sends two Set-Cookie values (session cookie plus PKCE verifier delete) and they must each become a separate header.workos.webhooks.constructEvent. Skipping the check lets anyone spoof user.deleted./ro:better-auth for the inverse case (own the user table, EU mandate, fully custom flows)/ro:nango when wiring third-party integrations (Nango sessions are scoped to your authenticated end-user)/ro:stripe when wiring payments (Stripe customers are linked to WorkOS user IDs)/ro:new-tanstack-app --with-auth=workos to scaffold a new app with WorkOS pre-wired/ro:cf-ship to ship after wiring/workos/authkit-session) for current syntaxllm-wiki-research/wiki/comparisons/auth-clerk-vs-better-auth.mddevelopment
--- name: worktree description: Coordinate multiple agents on one repo via a worktree-lock pool, so two agents never clobber each other's working tree. Acquire the first free slot (main, then beta/gamma… worktrees, created on demand), work there on your own branch, release when you've pushed. Use before modifying any repo that might be in use by another agent (factory, dataforce, etc.), or whenever you're told a repo is being worked on. Backed by `ro worktree`. category: development argument-hin
testing
--- name: ship description: Ship a feature branch the local-CI-first way — run the full local gate, push, open a PR, squash-merge, then deploy, without waiting on GitHub Actions. Use when a branch is ready for main and you want it merged and deployed now. Reads CI policy from `ro ci` (default skips remote CI because GitHub Actions billing keeps hitting limits). Sibling to /ro:gh-ship (waits on GitHub checks) and /ro:cf-ship (the deploy half). Triggers on "ship it", "ship this", "merge and deploy
testing
--- name: setup-logging description: Set up (or audit) the observability stack in a TanStack Start + Cloudflare Workers app so it is "diagnosable by default" — structured logging (logtape) with a request context carrying trace_id + userId + tenant/orgId, a trace_id propagated FE→BE→logs→Sentry→PostHog, Cloudflare Workers observability enabled, and Sentry + PostHog wired. Two modes: `setup` (wire it into an app) and `audit` (check an existing app + report gaps). Use when scaffolding a new app, wh
development
Manage credentials INSIDE the active ~/.claude/.env file — read which token/account to use for a given app (Simplicity vs Dataforce vs Ronan-personal), add or update a secret WITHOUT it passing through the chat (an interactive Terminal window prompts for it), and track secrets that were exposed in a transcript so they get rotated. Sibling to /ro:context (which switches WHICH env file is active). Use when the user wants to add an API key/token/secret, asks "which credential do I use for X", needs the env organized/labelled, or a secret was pasted into the chat and should be rotated.