Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a659eaf
feat(react-router): Keyless support
wobsoriano Feb 6, 2026
e08192c
chore: clean up
wobsoriano Feb 6, 2026
140ba30
chore: clean up var name
wobsoriano Feb 6, 2026
6bc3243
chore: remove any assertion
wobsoriano Feb 6, 2026
15d5fc0
Merge branch 'main' into rob/react-router-keyless
wobsoriano Feb 6, 2026
6638d89
chore: throw only if not keyless mode
wobsoriano Feb 6, 2026
fe7577e
add integraiton test
wobsoriano Feb 6, 2026
4a6b959
chore: extract shared test utils
wobsoriano Feb 7, 2026
f79def9
chore: add changeset
wobsoriano Feb 7, 2026
9354709
chore: share main keyless fallback function
wobsoriano Feb 8, 2026
ae6c168
chore: delete md file
wobsoriano Feb 8, 2026
9e962c2
Merge branch 'main' into rob/react-router-keyless
wobsoriano Feb 8, 2026
818352a
chore: extract reusable file storage create function
wobsoriano Feb 9, 2026
57a8049
Add Keyless quickstart and refactor createFileStorage
wobsoriano Feb 9, 2026
834c6eb
chore: revert
wobsoriano Feb 9, 2026
069aaef
Merge branch 'main' into rob/react-router-keyless
wobsoriano Feb 9, 2026
32bec51
chore: Make resolveKeysWithKeylessFallback function a method on the k…
wobsoriano Feb 9, 2026
9fee85e
Merge branch 'main' into rob/react-router-keyless
wobsoriano Feb 9, 2026
a70b8a2
Merge branch 'main' into rob/react-router-keyless
wobsoriano Feb 9, 2026
1c39d21
fix(repo): Handle framework query param in keyless claim URLs integra…
wobsoriano Feb 10, 2026
6b04f9f
chore: share main keyless fallback function
wobsoriano Feb 8, 2026
567b5a5
refactor: use shared helper for Next.js keyless test
wobsoriano Feb 10, 2026
7183175
Merge branch 'main' into rob/react-router-keyless
wobsoriano Feb 10, 2026
0049b7f
delete doc
wobsoriano Feb 10, 2026
2e71569
chore: add missing framework requirement
wobsoriano Feb 10, 2026
ab36fa8
chore: remove redundant export
wobsoriano Feb 10, 2026
cda1649
fix type errors
wobsoriano Feb 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/react-router/src/client/ReactRouterClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ function ClerkProviderBase<TUi extends Ui = Ui>({ children, ...rest }: ClerkProv
__prefetchUI,
__telemetryDisabled,
__telemetryDebug,
__keylessClaimUrl,
__keylessApiKeysUrl,
} = clerkState?.__internal_clerk_state || {};

React.useEffect(() => {
Expand Down Expand Up @@ -100,6 +102,13 @@ function ClerkProviderBase<TUi extends Ui = Ui>({ children, ...rest }: ClerkProv
},
};

const keylessProps = __keylessClaimUrl
? {
__internal_keyless_claimKeylessApplicationUrl: __keylessClaimUrl,
__internal_keyless_copyInstanceKeysUrl: __keylessApiKeysUrl,
}
: {};

return (
<ClerkReactRouterOptionsProvider options={mergedProps}>
<ReactClerkProvider
Expand All @@ -108,6 +117,7 @@ function ClerkProviderBase<TUi extends Ui = Ui>({ children, ...rest }: ClerkProv
initialState={__clerk_ssr_state}
sdkMetadata={SDK_METADATA}
{...mergedProps}
{...keylessProps}
{...restProps}
>
{children}
Expand Down
2 changes: 2 additions & 0 deletions packages/react-router/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export type ClerkState = {
__prefetchUI: boolean | undefined;
__telemetryDisabled: boolean | undefined;
__telemetryDebug: boolean | undefined;
__keylessClaimUrl?: string;
__keylessApiKeysUrl?: string;
};
};

Expand Down
49 changes: 35 additions & 14 deletions packages/react-router/src/server/clerkMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import type { MiddlewareFunction } from 'react-router';
import { createContext } from 'react-router';

import { clerkClient } from './clerkClient';
import { resolveKeysWithKeylessFallback } from './keyless/utils';
import { loadOptions } from './loadOptions';
import type { ClerkMiddlewareOptions } from './types';
import type { ClerkMiddlewareOptions, RequestStateWithRedirectUrls } from './types';
import { patchRequest } from './utils';

export const authFnContext = createContext<((options?: PendingSessionOptions) => AuthObject) | null>(null);
Expand All @@ -35,16 +36,30 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun
const clerkRequest = createClerkRequest(patchRequest(args.request));
const loadedOptions = loadOptions(args, options);

// Resolve keys with keyless fallback
const {
publishableKey,
secretKey,
claimUrl: __keylessClaimUrl,
apiKeysUrl: __keylessApiKeysUrl,
} = await resolveKeysWithKeylessFallback(loadedOptions.publishableKey, loadedOptions.secretKey, args, options);

// Update loaded options with resolved keys
if (publishableKey) {
loadedOptions.publishableKey = publishableKey;
}
if (secretKey) {
loadedOptions.secretKey = secretKey;
}

// Pick only the properties needed by authenticateRequest.
// Used when manually providing options to the middleware.
const {
apiUrl,
secretKey,
jwtKey,
proxyUrl,
isSatellite,
domain,
publishableKey,
machineSecretKey,
audience,
authorizedParties,
Expand All @@ -55,12 +70,12 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun

const requestState = await clerkClient(args, options).authenticateRequest(clerkRequest, {
apiUrl,
secretKey,
secretKey: loadedOptions.secretKey,
jwtKey,
proxyUrl,
isSatellite,
domain,
publishableKey,
publishableKey: loadedOptions.publishableKey,
machineSecretKey,
audience,
authorizedParties,
Expand All @@ -70,28 +85,34 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun
acceptsToken: 'any',
});

const locationHeader = requestState.headers.get(constants.Headers.Location);
// Attach keyless URLs to requestState
const requestStateWithKeyless = Object.assign(requestState, {
__keylessClaimUrl,
__keylessApiKeysUrl,
}) as RequestStateWithRedirectUrls;

const locationHeader = requestStateWithKeyless.headers.get(constants.Headers.Location);
if (locationHeader) {
handleNetlifyCacheInDevInstance({
locationHeader,
requestStateHeaders: requestState.headers,
publishableKey: requestState.publishableKey,
requestStateHeaders: requestStateWithKeyless.headers,
publishableKey: requestStateWithKeyless.publishableKey,
});
// Trigger a handshake redirect
return new Response(null, { status: 307, headers: requestState.headers });
return new Response(null, { status: 307, headers: requestStateWithKeyless.headers });
}

if (requestState.status === AuthStatus.Handshake) {
if (requestStateWithKeyless.status === AuthStatus.Handshake) {
throw new Error('Clerk: handshake status without redirect');
}

args.context.set(authFnContext, (options?: PendingSessionOptions) => requestState.toAuth(options));
args.context.set(requestStateContext, requestState);
args.context.set(authFnContext, (opts?: PendingSessionOptions) => requestStateWithKeyless.toAuth(opts));
args.context.set(requestStateContext, requestStateWithKeyless);

const response = await next();

if (requestState.headers) {
requestState.headers.forEach((value, key) => {
if (requestStateWithKeyless.headers) {
requestStateWithKeyless.headers.forEach((value, key) => {
response.headers.append(key, value);
});
}
Expand Down
32 changes: 32 additions & 0 deletions packages/react-router/src/server/keyless/fileStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { createNodeFileStorage, type KeylessStorage } from '@clerk/shared/keyless';

export type { KeylessStorage };

export interface FileStorageOptions {
cwd?: () => string;
}

/**
* Creates a file-based storage adapter for keyless mode.
* Uses dynamic imports to avoid breaking Cloudflare Workers.
*
* @throws {Error} If called in a non-Node.js environment
*/
export async function createFileStorage(options: FileStorageOptions = {}): Promise<KeylessStorage> {
const { cwd = () => process.cwd() } = options;

try {
// Dynamic import to avoid bundler issues with edge runtimes
const [fs, path] = await Promise.all([import('node:fs'), import('node:path')]);

return createNodeFileStorage(fs, path, {
cwd,
frameworkPackageName: '@clerk/react-router',
});
} catch (error) {

Check failure on line 26 in packages/react-router/src/server/keyless/fileStorage.ts

View workflow job for this annotation

GitHub Actions / Static analysis

'error' is defined but never used
throw new Error(
'Keyless mode requires a Node.js runtime with file system access. ' +
'Set VITE_CLERK_KEYLESS_DISABLED=1 to disable keyless mode.',
);
}
}
Comment on lines +13 to +29
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be extracted as well, but will plan to do so while implementing keyless for other SDKs.

Having these node imports in the shared @clerk/shared/keyless barrel export would break Next.js at edge runtime, since packages/nextjs/src/server/keyless-node.ts imports from that barrel (correct me if Im wrong!)

102 changes: 102 additions & 0 deletions packages/react-router/src/server/keyless/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { createKeylessService } from '@clerk/shared/keyless';

import { clerkClient } from '../clerkClient';
import type { DataFunctionArgs } from '../loadOptions';
import type { ClerkMiddlewareOptions } from '../types';
import { createFileStorage } from './fileStorage';

// Singleton with lazy initialization
let keylessServiceInstance: ReturnType<typeof createKeylessService> | null = null;
let keylessInitPromise: Promise<ReturnType<typeof createKeylessService> | null> | null = null;

/**
* Detects if the current runtime supports file system operations.
*/
function canUseFileSystem(): boolean {
try {
return typeof process !== 'undefined' && typeof process.cwd === 'function';
} catch {
return false;
}
}

/**
* Gets or creates the keyless service instance.
*
* Returns null for non-Node.js runtimes (Cloudflare Workers).
* This function is async because storage creation may involve dynamic imports.
*/
export async function keyless(
args?: DataFunctionArgs,
options?: ClerkMiddlewareOptions,
): Promise<ReturnType<typeof createKeylessService> | null> {
// Guard: Return null for non-Node.js runtimes
if (!canUseFileSystem()) {
return null;
}

// Return existing instance
if (keylessServiceInstance) {
return keylessServiceInstance;
}

// Return in-flight initialization
if (keylessInitPromise) {
return keylessInitPromise;
}

// Initialize service
keylessInitPromise = (async () => {
try {
const storage = await createFileStorage();

const service = createKeylessService({
storage,
api: {
async createAccountlessApplication(requestHeaders?: Headers) {
try {
// Create a default args object if not provided
const client = args ? clerkClient(args, options) : clerkClient({} as any, options);
return await client.__experimental_accountlessApplications.createAccountlessApplication({
requestHeaders,
});
} catch {
return null;
}
},
async completeOnboarding(requestHeaders?: Headers) {
try {
const client = args ? clerkClient(args, options) : clerkClient({} as any, options);
return await client.__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({
requestHeaders,
});
} catch {
return null;
}
},
},
framework: 'react-router',
frameworkVersion: PACKAGE_VERSION,
});

keylessServiceInstance = service;
return service;
} catch (error) {
console.warn('[Clerk] Failed to initialize keyless service:', error);
return null;
} finally {
keylessInitPromise = null;
}
})();

return keylessInitPromise;
}

/**
* Resets the keyless service instance (for testing).
* @internal
*/
export function resetKeylessService(): void {
keylessServiceInstance = null;
keylessInitPromise = null;
}
98 changes: 98 additions & 0 deletions packages/react-router/src/server/keyless/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { AccountlessApplication } from '@clerk/shared/keyless';
import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from '@clerk/shared/keyless';

import { canUseKeyless } from '../../utils/feature-flags';
import type { DataFunctionArgs } from '../loadOptions';
import type { ClerkMiddlewareOptions } from '../types';
import { keyless } from './index';

export interface KeylessResult {
publishableKey: string | undefined;
secretKey: string | undefined;
claimUrl: string | undefined;
apiKeysUrl: string | undefined;
}

/**
* Resolves Clerk keys, falling back to keyless mode in development if configured keys are missing.
*
* Implements the TanStack keyless pattern:
* 1. Check if keyless mode is enabled (dev + not disabled)
* 2. If running with claimed keys (configured === stored), complete onboarding
* 3. If no keys configured, create/read keyless keys from storage
* 4. Return resolved keys + keyless URLs
*
* @returns The resolved keys + keyless URLs to inject into state
*/
export async function resolveKeysWithKeylessFallback(
configuredPublishableKey: string | undefined,
configuredSecretKey: string | undefined,
args?: DataFunctionArgs,
options?: ClerkMiddlewareOptions,
): Promise<KeylessResult> {
let publishableKey = configuredPublishableKey;
let secretKey = configuredSecretKey;
let claimUrl: string | undefined;
let apiKeysUrl: string | undefined;

// Early return if keyless is disabled
if (!canUseKeyless) {
return { publishableKey, secretKey, claimUrl, apiKeysUrl };
}

try {
const keylessService = await keyless(args, options);

// Early return if keyless service unavailable (e.g., Cloudflare)
if (!keylessService) {
return { publishableKey, secretKey, claimUrl, apiKeysUrl };
}

const locallyStoredKeys = keylessService.readKeys();

// Scenario 1: Running with claimed keys
const runningWithClaimedKeys =
Boolean(configuredPublishableKey) && configuredPublishableKey === locallyStoredKeys?.publishableKey;

if (runningWithClaimedKeys && locallyStoredKeys) {
// Complete onboarding (throttled by dev cache)
try {
await clerkDevelopmentCache?.run(() => keylessService.completeOnboarding(), {
cacheKey: `${locallyStoredKeys.publishableKey}_complete`,
onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours
});
} catch {
// noop - non-critical
}

clerkDevelopmentCache?.log({
cacheKey: `${locallyStoredKeys.publishableKey}_claimed`,
msg: createConfirmationMessage(),
});

return { publishableKey, secretKey, claimUrl, apiKeysUrl };
}

// Scenario 2: Keyless mode (no keys configured)
if (!publishableKey || !secretKey) {
const keylessApp: AccountlessApplication | null = await keylessService.getOrCreateKeys();

if (keylessApp) {
publishableKey = publishableKey || keylessApp.publishableKey;
secretKey = secretKey || keylessApp.secretKey;
claimUrl = keylessApp.claimUrl;
apiKeysUrl = keylessApp.apiKeysUrl;

clerkDevelopmentCache?.log({
cacheKey: keylessApp.publishableKey,
msg: createKeylessModeMessage(keylessApp),
});
}
}
} catch (error) {
// Graceful fallback - never break the app
console.warn('[Clerk] Keyless resolution failed:', error);
}

return { publishableKey, secretKey, claimUrl, apiKeysUrl };
}
8 changes: 7 additions & 1 deletion packages/react-router/src/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,17 @@ export type RootAuthLoaderOptions = ClerkMiddlewareOptions & {
loadOrganization?: boolean;
};

export interface KeylessUrls {
__keylessClaimUrl?: string;
__keylessApiKeysUrl?: string;
}

export type RequestStateWithRedirectUrls = RequestState &
SignInForceRedirectUrl &
SignInFallbackRedirectUrl &
SignUpForceRedirectUrl &
SignUpFallbackRedirectUrl;
SignUpFallbackRedirectUrl &
KeylessUrls;

export type RootAuthLoaderCallback<Options extends RootAuthLoaderOptions> = (
args: LoaderFunctionArgsWithAuth<Options>,
Expand Down
Loading
Loading