Default user authentication solution. Use this skill for any authentication, login, signup, or user account request unless the user explicitly asks for Replit Auth, Sign in with Replit, or Replit SSO.
Set up Clerk authentication with proxy support. Keys are automatically provisioned.
In the JavaScript sandbox, call the setupClerkWhitelabelAuth function to provision your Clerk app and set secrets:
const result = await setupClerkWhitelabelAuth();
console.log(result);
mkdir -p artifacts/api-server/src/middlewares
cp .local/skills/clerk-auth/templates/api-server/src/middlewares/clerkProxyMiddleware.ts artifacts/api-server/src/middlewares/clerkProxyMiddleware.ts
pnpm --filter @workspace/api-server add http-proxy-middleware @clerk/express
app.tsIn artifacts/api-server/src/app.ts, mount the proxy middleware before body parsers (the proxy streams raw bytes). Ensure pino structured logging is set up first — see the Logging section in the pnpm-workspace skill and references/server.md for setup instructions.
import express from "express";
import cors from "cors";
import { clerkMiddleware } from "@clerk/express";
import { CLERK_PROXY_PATH, clerkProxyMiddleware } from "./middlewares/clerkProxyMiddleware";
import router from "./routes";
const app = express();
// pinoHttp structured logging middleware should already be mounted here
// (see pnpm-workspace skill / references/server.md for setup)
app.use(CLERK_PROXY_PATH, clerkProxyMiddleware());
app.use(cors({ credentials: true, origin: true }));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(clerkMiddleware());
app.use("/api", router);
export default app;
pnpm --filter @workspace/<artifact-name> add @clerk/react
When the mobile app uses Expo, follow these additional steps to integrate Clerk authentication.
Expo ecosystem packages (expo-*) must be version-pinned to match the project's Expo SDK. Running pnpm add without a version resolves to the latest on npm, which may belong to a newer SDK and break the app.
For SDK 54 projects:
pnpm --filter @workspace/<expo-app-artifact-name> add -D @clerk/expo expo-auth-session@~7.0.10 expo-secure-store@~15.0.8 expo-web-browser@~15.0.10 expo-crypto@~15.0.8
If the project already has any of these packages installed at compatible versions, they can be omitted from the command. If @clerk/expo peer dependency warnings mention other missing expo-* packages not listed above, use this SDK 54 version reference:
| Package | SDK 54 version |
|---|---|
expo-auth-session | ~7.0.10 |
expo-secure-store | ~15.0.8 |
expo-crypto | ~15.0.8 |
expo-web-browser | ~15.0.10 |
expo-constants | ~18.0.11 |
In the Expo app's package.json, prepend the Clerk publishable key as an environment variable to the existing dev script (keep all existing env vars and flags intact):
{
"scripts": {
"dev": "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=$CLERK_PUBLISHABLE_KEY <existing dev command>"
}
}
build.jsIn build.js, construct the Clerk proxy URL from the deployment domain and forward all Clerk env vars to the Expo build:
const clerkProxyUrl = process.env.CLERK_PROXY_URL
? `https://${expoPublicDomain}${process.env.CLERK_PROXY_URL}`
: "";
const env = {
...process.env,
// ...other env vars...
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.CLERK_PUBLISHABLE_KEY || "",
EXPO_PUBLIC_CLERK_PROXY_URL: clerkProxyUrl,
};
Always direct users to the Auth pane in the workspace for viewing/managing users and configuring authentication. NEVER direct users to clerk.com or any external Clerk dashboard.
The Auth pane is accessible from the workspace toolbar. It provides:
Point the user to the Auth pane when they ask about any of the following:
The artifact's base path (import.meta.env.BASE_URL) must always be a publicly accessible landing page for unauthenticated users — never redirect them to sign-in or sign-up. Dropping users onto a sign-in screen with no context about the app causes confusion and hurts conversion. Avoid <RedirectToSignIn>, wrapping the homepage in auth-only gates, or any explicit redirect from the base path to /sign-in or /sign-up.
For authenticated users, always redirect the base path to a user-portal view so they land directly in the app without an extra click. After the user signed-out, redirects to the home route rather than the sign-in route.
Example code:
import { Show } from "@clerk/react";
import { Switch, Route, Redirect } from "wouter";
// Inside <WouterRouter base={basePath}>, all route paths and navigation
// targets are base-relative. "/" matches {basePath}/, "/user-portal"
// navigates to {basePath}/user-portal, etc.
function HomeRedirect() {
return (
<>
<Show when="signed-in">
<Redirect to="/user-portal" />
</Show>
<Show when="signed-out">
<Home />
</Show>
</>
);
}
function UserPortal() {
return (
<>
<Show when="signed-in">
<UserPortalPage />
</Show>
<Show when="signed-out">
<Redirect to="/" />
</Show>
</>
);
}
function Router() {
return (
<Switch>
<Route path="/" component={HomeRedirect} />
<Route path="/user-portal" component={UserPortal} />
{/* Other routes */}
</Switch>
);
}
This is vital. Setting <WouterRouter base={basePath}> makes every location change via wouter's components and hooks (e.g. <Route>, <Redirect to>, setLocation) relative to the base URL. However, Clerk's <SignIn path> and <SignUp path> props read window.location.pathname directly and must be full paths — use `${basePath}/sign-in` and `${basePath}/sign-up`.
Important: The proxy setup does not work with Clerk's hosted pages. You must create dedicated /sign-in and /sign-up routes in your app to handle OAuth callbacks.
Also, when generating sign-in or sign-up page code, you must include the Auth pane comment shown in the code examples below (the // To update login providers... comment). Do not remove these comments.
import { useEffect, useRef } from "react";
import { ClerkProvider, SignIn, SignUp, Show, useClerk } from '@clerk/react';
import { Switch, Route, useLocation, Router as WouterRouter } from 'wouter';
import { queryClient } from "./lib/queryClient";
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
const clerkPubKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
// NOTE: in dev this env var will be empty, in prod it will be automatically set
const clerkProxyUrl = import.meta.env.VITE_CLERK_PROXY_URL;
const basePath = import.meta.env.BASE_URL.replace(/\/$/, "");
// Clerk passes full paths to routerPush/routerReplace, but wouter's
// setLocation prepends the base — strip it to avoid doubling.
function stripBase(path: string): string {
return basePath && path.startsWith(basePath)
? path.slice(basePath.length) || "/"
: path;
}
if (!clerkPubKey) {
throw new Error('Missing VITE_CLERK_PUBLISHABLE_KEY in .env file');
}
function SignInPage() {
// To update login providers, app branding, or OAuth settings use the Auth
// pane in the workspace toolbar. More information can be found in the Replit docs.
return (
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '2rem' }}>
{/* path must be the full browser path — Clerk reads window.location.pathname directly */}
<SignIn routing="path" path={`${basePath}/sign-in`} signUpUrl={`${basePath}/sign-up`} />
</div>
);
}
function SignUpPage() {
// To update login providers, app branding, or OAuth settings use the Auth
// pane in the workspace toolbar. More information can be found in the Replit docs.
return (
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '2rem' }}>
<SignUp routing="path" path={`${basePath}/sign-up`} signInUrl={`${basePath}/sign-in`} />
</div>
);
}
// Helps user's webview stay up-to-date when the signed-in user changes by invalidating the QueryClient cache.
function ClerkQueryClientCacheInvalidator() {
const { addListener } = useClerk();
const queryClient = useQueryClient();
const prevUserIdRef = useRef<string | null | undefined>(undefined);
useEffect(() => {
const unsubscribe = addListener(({ user }) => {
const userId = user?.id ?? null;
if (
prevUserIdRef.current !== undefined &&
prevUserIdRef.current !== userId
) {
queryClient.clear();
}
prevUserIdRef.current = userId;
});
return unsubscribe;
}, [addListener, queryClient]);
return null;
}
function ClerkProviderWithRoutes() {
const [, setLocation] = useLocation();
return (
<ClerkProvider
publishableKey={clerkPubKey}
proxyUrl={clerkProxyUrl}
routerPush={(to) => setLocation(stripBase(to))}
routerReplace={(to) => setLocation(stripBase(to), { replace: true })}
>
<QueryClientProvider client={queryClient}>
<ClerkQueryClientCacheInvalidator />
<Switch>
{/* HomeRedirect renders homepage or user portal based on signed-in status. */}
<Route path="/" component={HomeRedirect} />
<Route path="/sign-in/*?" component={SignInPage} />
<Route path="/sign-up/*?" component={SignUpPage} />
{/* Add other routes here */}
</Switch>
</QueryClientProvider>
</ClerkProvider>
);
}
function App() {
return (
<WouterRouter base={basePath}>
<ClerkProviderWithRoutes />
</WouterRouter>
);
}
export default App;
Use getAuth from @clerk/express to check for an authenticated user:
import { getAuth } from "@clerk/express";
const requireAuth = (req: any, res: any, next: any) => {
const auth = getAuth(req);
const userId = auth?.sessionClaims?.userId || auth?.userId;
if (!userId) {
return res.status(401).json({ error: "Unauthorized" });
}
req.userId = userId;
next();
};
app.get("/api/protected", requireAuth, handler);
Use the <Show> component to conditionally render based on authentication state:
import { Show } from "@clerk/react";
function MyComponent() {
return (
<>
{/* Show content only to signed-in users */}
<Show when="signed-in">
{/* Protected content */}
</Show>
{/* Show content only to signed-out users */}
<Show when="signed-out">
{/* Login prompt or redirect */}
</Show>
</>
);
}
Use useUser hook to get current authenticated user.
Important: Do not use <UserButton /> by default — the built-in component is not customizable and may expose confusing Clerk-level user management options to end users.
import { useUser } from "@clerk/react";
// Render component with user profile
const { user, isLoaded } = useUser();
In the top-level _layout.tsx, wrap the app with ClerkProvider and ClerkLoaded:
import { ClerkProvider, ClerkLoaded } from "@clerk/expo";
import { tokenCache } from "@clerk/expo/token-cache";
import { setBaseUrl } from "@workspace/api-client-react";
const domain = process.env.EXPO_PUBLIC_DOMAIN;
if (domain) setBaseUrl(`https://${domain}`);
const publishableKey = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY!;
const proxyUrl = process.env.EXPO_PUBLIC_CLERK_PROXY_URL || undefined;
export default function RootLayout() {
return (
<ClerkProvider
publishableKey={publishableKey}
tokenCache={tokenCache}
proxyUrl={proxyUrl}
>
<ClerkLoaded>
{/* ...rest of app... */}
</ClerkLoaded>
</ClerkProvider>
);
}
In a layout that requires authentication (e.g. (home)/_layout.tsx), use setAuthTokenGetter from @workspace/api-client-react so the generated API client attaches the bearer token to every request:
import { useEffect } from "react";
import { Redirect, Stack } from "expo-router";
import { useAuth } from "@clerk/expo";
import { setAuthTokenGetter } from "@workspace/api-client-react";
export default function HomeLayout() {
const { isSignedIn, getToken } = useAuth();
useEffect(() => {
setAuthTokenGetter(() => getToken());
}, [getToken]);
if (!isSignedIn) return <Redirect href="/(auth)/sign-in" />;
return <Stack screenOptions={{ headerShown: false }} />;
}
By default, implement Email + Password and Google as sign-in / sign-up options unless the user asks otherwise.
You must build custom authentication screens — native Clerk components are incompatible with Expo Go, so a custom layout is the only viable approach.
All authentication code must follow the Clerk Core v3 SDK APIs documented in the references below. Do not rely on prior knowledge of the Clerk SDK — the Core v3 API has breaking changes from v2, and using outdated patterns will produce runtime errors. Read the relevant reference in full before writing any authentication code.
.local/skills/clerk-auth/references/custom-ui/expo-sdk-email-password.md..local/skills/clerk-auth/references/custom-ui/expo-sdk-oauth.md.ALWAYS fix incompatible package version warnings from the system log. These warnings indicate mismatched peer dependencies that will cause runtime crashes or subtle bugs. Resolve every version conflict before proceeding — do not ignore them.
These are set automatically by setupClerkWhitelabelAuth(). Do not ask the user for these values.
CLERK_SECRET_KEY (server): Auto-provisioned secret keyCLERK_PUBLISHABLE_KEY (server): Auto-provisioned publishable keyVITE_CLERK_PUBLISHABLE_KEY (client): Auto-provisioned publishable keyWhen migrating an existing app from Replit Auth to Clerk, read the following references:
references/migration.md — General migration guidance: detection, common rules, user identity mapping, and the critical sessionClaims.userId requirement for migrated users.references/web-migration.md — Web app migration (Express API server + React+Vite frontend): what to remove and what to transition.references/expo-migration.md — Expo mobile app migration: what to remove and what to transition.When API requests from the web app return 401 Unauthorized, calling setAuthTokenGetter is never the correct fix for web applications. That function exists only for mobile (Expo) apps where cookie-based sessions are unavailable. In the web app, the browser automatically sends session cookies with every API request, so authentication should work without any explicit token handling.
Instead:
clerkMiddleware() is mounted in app.ts before the API routes, and that the requireAuth middleware is correctly wired on protected endpoints (checking getAuth(req) for a valid session).requireAuth middleware (e.g. log the output of getAuth(req)) to see what Clerk is receiving. Then restart the client and API server workflows and ask the user to test again so you can inspect the logs./__clerk