One Public Surface: Server Components, Passkeys, and a WebView Shell
React Server Components on Lambda Web Functions as the only public surface: data behind one IAM role, passkeys handled server-side, and a React Native WebView shell.
A web app and a mobile app on AWS usually get two public surfaces. The browser calls a BFF with a session cookie, and the phone calls an API with a bearer token. Every authorization rule, rate limit, and schema change then lands twice, and the second surface gets the least review. Removing it only works if the mobile app can reuse the first surface for everything, sign-in included. A WebView does not get WebAuthn by default on either platform. In the trial build, the React Server Components page server on a Lambda Web Function is the only thing with a public address. The data behind it sits in a table that one execution role can query. The Cognito passkey ceremony runs server-side, and a React Native WebView shell reuses all of it on iOS and Android. The sections below follow that boundary through data access, sign-in, the session, and the shell. They then list where it moves: platform attestation, the CSRF origin check behind a TLS-terminating proxy, and per-client chrome.
The stack is React Router 8.5.1 in RSC Framework Mode on Lambda Web Functions behind CloudFront, plus a Cognito user pool with passkeys as a first factor. RSC support is experimental, so pin exact versions. The shell is Expo SDK 58 / React Native 0.88 on react-native-webview 14.0.1, patched below with patch-package, so pin that exact version as well. The hosting half (the entry shim, NODE_ENV, streaming, the CloudFront hop and its latency numbers) is in the predecessor post. The Web Functions resource model is in the Hono lambdalith post. The code, the deploy scripts, and the WebView patch are in ayhansipahi/rsc-mfe-demo (opens in new tab); every snippet below is quoted from it.
The field has three standing answers for the phone. An API with an SDK and bearer tokens (REST or GraphQL, Amplify on the device) wins on offline writes, native UI on most screens, and partners who consume the API anyway. Hotwire Native and Capacitor-style shells share the shape used here, a native frame around web content. But they usually sit in front of a BFF or a monolith that also exposes a public API. Client-side micro-frontends (module federation, single-spa) compose in the browser, so every fragment needs an API and the boundary question multiplies.
The Boundary in One Diagram#
The browser and the shell send every request with the session cookie to CloudFront, which routes by path: /orders* to the orders function, everything else to the account function. The orders function queries the orders table with dynamodb:Query, keyed by the identity it has already verified. For sign-in the account function calls Cognito with AdminInitiateAuth and AdminRespondToAuthChallenge, which need its IAM credentials. The page calls navigator.credentials.get() against the platform authenticator and never leaves its origin. The private zone holds AWS services with endpoints of their own. What makes it private is that the product exposes none of them, and each is reachable by one execution role. The diagram matters for the arrows it does not have: nothing from the client to Cognito, nothing from the client to the data, and nothing from one function to another.
The Vertical Owns Its Data#
The orders vertical owns its data, and there is no orders service to call. infra/orders-table.sh creates one DynamoDB table with the Cognito sub as partition key and orderId as sort key. It also seeds three rows for the demo user. The web function reads it directly; the only thing between the page and the rows is the function’s execution role.
// web/app/orders.server.ts
export async function listOrders(user: { sub: string }): Promise<Order[]> {
doc ??= DynamoDBDocumentClient.from(new DynamoDBClient({}));
const out = await doc.send(
new QueryCommand({
TableName: process.env.ORDERS_TABLE!,
KeyConditionExpression: "#owner = :sub",
ExpressionAttributeNames: { "#owner": "owner" },
ExpressionAttributeValues: { ":sub": user.sub },
}),
);
return (out.Items ?? []) as Order[];
}
The role holds one inline policy, dynamodb:Query on that one table ARN. infra/web-role.sh attaches it when ORDERS_TABLE=1 and removes it otherwise.
# infra/web-role.sh
if [ "${ORDERS_TABLE:-0}" = "1" ]; then
aws iam put-role-policy --role-name "$NAME-role" --policy-name query-orders --policy-document "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":\"dynamodb:Query\",\"Resource\":\"arn:aws:dynamodb:$REGION:$ACCOUNT:table/rsc-mfe-orders\"}]}"
else
aws iam delete-role-policy --role-name "$NAME-role" --policy-name query-orders 2>/dev/null || true
fi
What this buys: nothing behind the page server is reachable from the internet, so there is no endpoint to scan, rate-limit, or version for a second client. What it costs: the web function is now the authorization point. Here the key condition on sub is the object-level check, since a query only returns rows under the caller’s own partition key. Any finer rule (a shared order, a support role) would be written into the same loader. Object-level authorization covers what those checks look like.
The first cut of this demo took the obvious route to a private service: a Lambda with no URL, invoked from the web function with lambda:InvokeFunction. The Lambda Developer Guide, under the heading “Anti-patterns in Lambda-based event-driven applications”, says: “Lambda functions directly calling other Lambda functions is generally an anti-pattern due to cost and complexity concerns”. The page exempts durable functions. The reasons the page lists map onto the first cut: the caller pays while it waits, error handling nests, the slowest function bounds availability, and both need equal concurrency. The rule that replaced it: a vertical reads and writes its own data over IAM, and work that crosses verticals goes through events (EventBridge, SQS). An unavoidable synchronous service gets a private interface with IAM at the door, such as a private API Gateway or a VPC Lattice service. AWS Prescriptive Guidance says synchronous calls, “in many cases, are the widely accepted standard for communication”. It lists predictable flow control and strong consistency among their benefits, so that path is a stated exception.
Passkeys With the Browser Kept Out of Cognito#
Sign-in is a WebAuthn ceremony, and both halves of it that touch Cognito run inside the web function. The page talks to navigator.credentials and to /auth/passkey on its own origin; it never imports the Cognito SDK, never holds the client secret, and never sees the access token.
Cognito’s USER_AUTH flow is choice-based: the caller names a preferred challenge and Cognito answers with it or with the alternatives it can offer. cognito.server.ts is the only module that imports the Cognito SDK. For sign-in it calls AdminInitiateAuth with WEB_AUTHN as the preferred challenge and later completes the challenge with AdminRespondToAuthChallenge and the assertion the page produced. Every call carries a SECRET_HASH, the HMAC-SHA256 of username plus client ID under the client secret.
// web/app/cognito.server.ts
const UserPoolId = () => process.env.COGNITO_USER_POOL_ID!;
const ClientId = () => process.env.COGNITO_CLIENT_ID!;
// Cognito's proof that the caller holds the client secret: HMAC-SHA256(username + clientId, secret).
const secretHash = (username: string) =>
createHmac("sha256", process.env.COGNITO_CLIENT_SECRET!).update(username + ClientId()).digest("base64");
export async function passkeySignInStart(username: string) {
const r = await client().send(
new AdminInitiateAuthCommand({
UserPoolId: UserPoolId(),
ClientId: ClientId(),
AuthFlow: "USER_AUTH",
AuthParameters: { USERNAME: username, PREFERRED_CHALLENGE: "WEB_AUTHN", SECRET_HASH: secretHash(username) },
}),
);
if (r.ChallengeName !== "WEB_AUTHN" || !r.Session) throw new Error(`unexpected challenge ${r.ChallengeName}`);
return { session: r.Session, options: JSON.parse(r.ChallengeParameters!.CREDENTIAL_REQUEST_OPTIONS!) };
}
export async function passkeySignInFinish(username: string, session: string, credential: unknown) {
const r = await client().send(
new AdminRespondToAuthChallengeCommand({
UserPoolId: UserPoolId(),
ClientId: ClientId(),
ChallengeName: "WEB_AUTHN",
Session: session,
ChallengeResponses: { USERNAME: username, CREDENTIAL: JSON.stringify(credential), SECRET_HASH: secretHash(username) },
}),
);
return tokenOf(r);
}
InitiateAuth and RespondToAuthChallenge, the public pair, are unauthenticated operations: anyone who learns the client ID can start a sign-in from the internet, and a client ID is not a secret. The Admin pair needs IAM credentials, so the sign-in entry point is the account function’s role, the same shape as the orders table. infra/web-role.sh attaches cognito-idp:AdminInitiateAuth and cognito-idp:AdminRespondToAuthChallenge on the pool ARN when COGNITO_ADMIN=1. The first cut used the public pair with a public client and kept the client ID on the server. That hid the entry point and nothing more, since the pool’s sign-in API stayed open to anyone with the ID. Verified through CloudFront after the change: password sign-in, passkey registration, sign-out, and passkey sign-in.
Registration needs a signed-in user, so it runs on the access token from the cookie. StartWebAuthnRegistration returns CredentialCreationOptions, the page runs startRegistration({ optionsJSON }) from @simplewebauthn/browser, and CompleteWebAuthnRegistration stores the result.
// web/app/cognito.server.ts
export async function passkeyRegisterStart(accessToken: string) {
const r = await client().send(new StartWebAuthnRegistrationCommand({ AccessToken: accessToken }));
return r.CredentialCreationOptions;
}
export async function passkeyRegisterFinish(accessToken: string, credential: unknown) {
await client().send(new CompleteWebAuthnRegistrationCommand({ AccessToken: accessToken, Credential: credential as never }));
}
One resource route carries the four intents. It answers every call with Cache-Control: private, no-store, and on signin-finish it turns the access token into the session cookie.
// web/app/routes/auth.passkey.ts
export async function action({ request }: { request: Request }) {
const body = await request.json().catch(() => ({}));
try {
switch (body.intent) {
case "signin-start":
return Response.json(await passkeySignInStart(String(body.email)), { headers: noStore });
case "signin-finish": {
const token = await passkeySignInFinish(String(body.email), String(body.session), body.credential);
return new Response(null, { status: 204, headers: { ...noStore, "Set-Cookie": sessionCookie(token) } });
}
case "register-start": {
const user = await getUser(request);
if (!user) return new Response("unauthorized", { status: 401 });
return Response.json({ options: await passkeyRegisterStart(user.token) }, { headers: noStore });
}
case "register-finish": {
const user = await getUser(request);
if (!user) return new Response("unauthorized", { status: 401 });
await passkeyRegisterFinish(user.token, body.credential);
return new Response(null, { status: 204, headers: noStore });
}
default:
return new Response("unknown intent", { status: 400 });
}
} catch (e) {
// Cognito's message is fine for a demo; a product would map these.
return new Response(e instanceof Error ? e.message : "auth failed", { status: 400, headers: noStore });
}
}
The pool side is three calls in infra/cognito.sh, which also seeds one user. They create the pool on the ESSENTIALS tier with PASSWORD and WEB_AUTHN as first factors, since passkeys and USER_AUTH need the Essentials or Plus plan. They set the relying party ID to the public domain. They create a confidential client with ALLOW_USER_AUTH and --generate-secret, and the script prints the client ID and the secret for the web function’s environment. Password sign-in uses the same USER_AUTH flow with PREFERRED_CHALLENGE: PASSWORD, so the login page offers both buttons on one form.
# infra/cognito.sh
POOL=$(aws cognito-idp create-user-pool --region "$REGION" --pool-name rsc-mfe-demo --user-pool-tier ESSENTIALS \
--policies '{"PasswordPolicy":{"MinimumLength":8},"SignInPolicy":{"AllowedFirstAuthFactors":["PASSWORD","WEB_AUTHN"]}}' \
--admin-create-user-config '{"AllowAdminCreateUserOnly":true}' \
--query UserPool.Id --output text)
aws cognito-idp set-user-pool-mfa-config --region "$REGION" --user-pool-id "$POOL" --mfa-configuration OFF \
--web-authn-configuration "{\"RelyingPartyId\":\"$RP\",\"UserVerification\":\"required\"}" >/dev/null
# A confidential client: the secret stays in the web function's environment, and sign-in goes through
# the Admin* operations under IAM, so no browser can start a sign-in for this client on its own.
read -r CLIENT SECRET < <(aws cognito-idp create-user-pool-client --region "$REGION" --user-pool-id "$POOL" --client-name web-confidential \
--explicit-auth-flows ALLOW_USER_AUTH ALLOW_REFRESH_TOKEN_AUTH --generate-secret \
--query 'UserPoolClient.[ClientId,ClientSecret]' --output text)
The boundary depends on what stays on the server: the client secret, the IAM credentials the Admin calls need, and the access token never reach the page. The one value that does round-trip is Cognito’s challenge Session string, returned by signin-start and posted back unchanged by signin-finish. It is opaque, single-use, and tied to this one pending challenge; a replay would need the SECRET_HASH and the IAM credentials the client never has. The client ends up holding a credential it cannot export and a cookie it cannot read.
The Session Cookie and Its Ceiling#
The session is the Cognito access token in an HttpOnly; Secure; SameSite=Lax cookie, verified on every request against the pool’s JWKS with aws-jwt-verify. There is no session store, so the web function stays stateless, and page JavaScript cannot read the token because of HttpOnly.
// web/app/session.server.ts
const getVerifier = () =>
(verifier ??= CognitoJwtVerifier.create({
userPoolId: process.env.COGNITO_USER_POOL_ID!,
clientId: process.env.COGNITO_CLIENT_ID!,
tokenUse: "access",
}));
export async function getUser(request: Request): Promise<SessionUser | null> {
const token = readCookie(request);
if (!token) return null;
try {
const payload = await getVerifier().verify(token);
return { sub: payload.sub, username: String(payload.username), token };
} catch {
return null;
}
}
export const sessionCookie = (token: string) =>
`${COOKIE}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600`;
export const clearedCookie = `${COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`;
The demo keeps the access token as the whole session. Max-Age=3600 matches the one-hour default access-token lifetime, the demo discards the refresh token Cognito returns, there is no revocation list, and sign-out clears the cookie and nothing else. A production build would add one of two things. The first is that refresh token in a second HttpOnly cookie, with a /session/refresh action that exchanges it before the hour runs out. The second is an opaque session ID backed by DynamoDB, which makes revocation a delete and keeps tokens off the wire. Both keep the boundary as drawn, because both run inside the web function.
The Mobile Shell#
The shell is one Expo app with one file of product-relevant code. The native layer owns the frame: a tab bar made of Pressable rows, safe-area insets from react-native-safe-area-context, and one WebView per tab pinned to the one origin. It has no API client and no token.
// mobile/App.tsx
// The product UI is the web function's pages. The shell owns the frame: a native tab bar, one
// WebView per tab pinned to the origin, cookies shared with the native store. No API client, no
// token: the session cookie is born inside the WebView, and the native layer only picks URLs.
import { useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import { WebView } from "react-native-webview";
export const ORIGIN = "https://rscmfe.sph.sh";
// Appended to the User-Agent. The web app keys its chrome off it and renders pages without the web nav.
const SHELL_UA = "RSCShell/1";
const tabs = [
{ key: "home", label: "Home", path: "/" },
{ key: "orders", label: "Orders", path: "/orders" },
] as const;
type TabKey = (typeof tabs)[number]["key"];
export default function App() {
const [active, setActive] = useState<TabKey>("home");
return (
<SafeAreaProvider>
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
{/* One WebView per tab: each keeps its own history and scroll, like native stacks. Inactive
tabs stay laid out at full size (opacity, not display:none): a WKWebView that was laid
out at zero height scrolls its content to the bottom when it is shown. */}
<View style={styles.pages}>
{tabs.map((tab) => (
<View
key={tab.key}
style={[
StyleSheet.absoluteFill,
active !== tab.key && styles.hidden,
]}
pointerEvents={active === tab.key ? "auto" : "none"}
>
<WebView
style={styles.page}
source={{ uri: `${ORIGIN}${tab.path}` }}
applicationNameForUserAgent={SHELL_UA}
sharedCookiesEnabled
allowsBackForwardNavigationGestures
// Anything off-origin would leave the product; keep it inside the shell's one domain.
// Compare origins, not prefixes: a startsWith check also matches look-alike hosts.
onShouldStartLoadWithRequest={(req) => new URL(req.url).origin === ORIGIN}
/>
</View>
))}
</View>
<View style={styles.bar}>
{tabs.map((tab) => (
<Pressable
key={tab.key}
onPress={() => setActive(tab.key)}
style={styles.tab}
>
<Text
style={[styles.label, active === tab.key && styles.labelActive]}
>
{tab.label}
</Text>
</Pressable>
))}
</View>
</SafeAreaView>
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: "#fff" },
pages: { flex: 1 },
page: { flex: 1 },
hidden: { opacity: 0 },
bar: {
flexDirection: "row",
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: "#d4d4d4",
},
tab: { flex: 1, alignItems: "center", paddingVertical: 12 },
label: { fontSize: 13, color: "#737373" },
labelActive: { color: "#171717", fontWeight: "600" },
});
Three decisions carry the design. The first is one WebView per tab, so each tab keeps its own history and scroll position. allowsBackForwardNavigationGestures gives each tab the iOS swipe-back; the prop is iOS and macOS only, so Android would need a BackHandler that calls goBack(). Cookies are shared through the default WKWebsiteDataStore on iOS and the process-wide CookieManager on Android, so the session stays one session. The second is that inactive tabs stay laid out at full size, hidden with opacity and never with display: "none". On the iOS 27 simulator, a WKWebView laid out at zero height showed its content scrolled to the bottom when revealed; keeping the layout prevented it. The third is that the native layer never holds a token. The session cookie comes from the page server’s Set-Cookie inside the WebView, and in the trial it survived an app kill and relaunch on both platforms.
applicationNameForUserAgent appends RSCShell/1 to the User-Agent; the next section shows what the server does with that.
Hotwire Native is the field’s name for a native frame around web content. What differs here is that the page server is the BFF, nothing behind it has an address, and sign-in needs no bridge.
Per-Client Chrome From the Server#
The web app renders a top navigation for browsers and nothing for the shell, which draws its own tab bar. The decision is made on the server from the User-Agent token. The root loader reads it, and the root component skips <Nav> when it is present.
// web/app/root.tsx
// The chrome is per client: the mobile shell announces itself in the User-Agent and draws its own
// tab bar, so its pages render without the web nav. This distribution's cache policy has a default
// TTL of 0, so the two variants never share a cache entry; a caching policy would need the token in its key.
export function loader({ request }: Route.LoaderArgs) {
return { shell: (request.headers.get("user-agent") ?? "").includes("RSCShell/") };
}
// The User-Agent does not change between navigations and NavLink computes the active state on the
// client, so a link click does not need the root loader again. Form submits still revalidate.
export function shouldRevalidate({ formMethod, defaultShouldRevalidate }: Route.ShouldRevalidateFunctionArgs) {
return formMethod ? defaultShouldRevalidate : false;
}
export default function App({ loaderData }: Route.ComponentProps) {
return (
<>
{loaderData.shell ? null : <Nav />}
<div className="mx-auto max-w-3xl space-y-4 p-6">
<Outlet />
</div>
</>
);
}
shouldRevalidate returns false for link navigations, so the root loader runs once per document; form submits still revalidate.
The home route can no longer be prerendered: a static index.html would reach the shell with the web nav, so react-router.config.ts in the demo is empty. In the trial, this distribution’s cache policy has a default TTL of 0, which keeps the two variants apart. A distribution that caches the home page needs the User-Agent token in its cache key.
Associated Domains on iOS#
For the passkey ceremony to run inside the WebView on iOS (iOS 16 or later, per Apple’s Supporting passkeys page), the app and the domain have to vouch for each other. The app side is one entry in app.json.
"ios": {
"bundleIdentifier": "sh.sph.rscmfe",
"appleTeamId": "3EWL9B9GPV",
"supportsTablet": false,
"associatedDomains": ["webcredentials:rscmfe.sph.sh"]
}
Development builds in the trial added ?mode=developer to the domain. Per Apple’s entitlement page, that suffix applies only on devices in developer mode, to apps signed with a development profile, after the user opts in. The TestFlight build drops it, as above. The domain side is the apple-app-site-association file. The web function serves it from an environment variable, so the relying party ID, the WebView origin, and the associated domain are one hostname.
// web/index.js
// App-to-domain association for passkeys in the mobile shell. Served from here so the RP ID,
// the WebView origin and the associated domain are one hostname.
const appleAppIds = (process.env.APPLE_APP_IDS ?? "").split(",").filter(Boolean);
app.get("/.well-known/apple-app-site-association", (_req, res) => {
res.type("application/json").send({ webcredentials: { apps: appleAppIds } });
});
The value is the Team ID and the bundle identifier joined with a dot. Nothing else was needed: no WKWebView configuration beyond react-native-webview’s defaults and no native module.
In the shell, tapping the registration button on the orders page produced the system “Save a passkey?” sheet, which named the app and its place in Passwords. Face ID followed, then the page status “Passkey saved. Sign out and sign in with it.” After sign-out, the passkey button on the login page produced the sheet “Sign in to “RSC Shell” with your passkey for “demo@example.com”?”. Face ID followed, then the orders page. The sheet names the app while the credential is bound to the domain, so the user sees one identity in Safari and in the app.
The Android WebView Setting Behind a Patch#
Android WebView ships with WebAuthn off. An app opts in with WebSettingsCompat.setWebAuthenticationSupport(settings, WEB_AUTHENTICATION_SUPPORT_FOR_APP) from androidx.webkit, behind a WebViewFeature.isFeatureSupported(WEB_AUTHENTICATION) check. react-native-webview 14.0.1 has no prop for it, so the trial added the guarded call to RNCWebViewManagerImpl.kt with patch-package. The diff is the whole fix.
--- a/node_modules/react-native-webview/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManagerImpl.kt
+++ b/node_modules/react-native-webview/android/src/main/java/com/reactnativecommunity/webview/RNCWebViewManagerImpl.kt
@@ -82,6 +82,11 @@ class RNCWebViewManagerImpl(private val newArch: Boolean = false) {
settings.allowFileAccessFromFileURLs = false
settings.allowUniversalAccessFromFileURLs = false
settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
+ // Passkeys inside the WebView: Credential Manager answers navigator.credentials for origins
+ // this app is linked to through assetlinks.json (androidx.webkit 1.12+, gated at runtime by isFeatureSupported).
+ if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_AUTHENTICATION)) {
+ WebSettingsCompat.setWebAuthenticationSupport(settings, WebSettingsCompat.WEB_AUTHENTICATION_SUPPORT_FOR_APP)
+ }
// Fixes broken full-screen modals/galleries due to body height being 0.
webView.layoutParams = ViewGroup.LayoutParams(
The domain side is assetlinks.json, served by the shim next to the Apple file. It lists the package name and the signing certificate’s SHA-256 under the get_login_creds and handle_all_urls relations. Android’s WebView passkey guide requires the app to be associated with the site through Digital Asset Links. The trial ran only the positive path, with the file in place.
// web/index.js
const androidPackage = process.env.ANDROID_PACKAGE;
const androidCertShas = (process.env.ANDROID_CERT_SHA256 ?? "").split(",").filter(Boolean);
app.get("/.well-known/assetlinks.json", (_req, res) => {
res.type("application/json").send(
androidPackage
? [{ relation: ["delegate_permission/common.get_login_creds", "delegate_permission/common.handle_all_urls"], target: { namespace: "android_app", package_name: androidPackage, sha256_cert_fingerprints: androidCertShas } }]
: [],
);
});
With both in place the trial observed the logcat line CredentialManager: starting executeCreateCredential with callingPackage: sh.sph.rscmfe, followed by the system sheet “Create passkey on another device?” listing demo@example.com. On the emulator, which had no Google account, the only provider was the hybrid QR flow, so the local ceremony stopped at the sheet. A device with Google Password Manager signed in would offer it as the local provider. Cancelling returns NotAllowedError to the page, and a wrong setting produces the same error. The logcat line is therefore the thing to check when the sheet does not appear.
One layout detail belongs here. React Native 0.88 draws edge-to-edge, and the core SafeAreaView is iOS-only and deprecated. A WebView without react-native-safe-area-context therefore sits under the status bar, and taps on the page’s top row land there instead. The SafeAreaProvider and SafeAreaView edges={["top", "bottom"]} pair in App.tsx keeps the page below it.
The Vertical Split in Practice#
Route-based micro-frontends on this base, the single-app relative of server-side composition, are a CloudFront routing decision plus three build-time facts. The demo is split into two verticals behind the one domain. The account function (rsc-mfe-web) serves /, /login, /logout, /auth/passkey, and the two well-known files; the orders function (rsc-mfe-orders-web) serves /orders and its own assets. Both are built from one source tree, with VERTICAL selecting the route set, which stands in for two repositories sharing a chrome package.
// web/app/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";
// One source tree, built once per vertical. VERTICAL selects the route set at build time:
// "account" owns the home page and everything auth, "orders" owns /orders. CloudFront sends
// /orders* to the orders function and the rest to the account function; unset builds everything.
const account = [
index("routes/home.tsx"),
route("login", "routes/login.tsx"),
route("logout", "routes/logout.ts"),
// Resource route (no component): the passkey ceremony's server half.
route("auth/passkey", "routes/auth.passkey.ts"),
];
const orders = [route("orders", "routes/orders.tsx")];
const vertical = process.env.VERTICAL;
export default (vertical === "orders" ? orders : vertical === "account" ? account : [...account, ...orders]) satisfies RouteConfig;
The orders function answers / with 404 and the account function answers /orders with 404; CloudFront is what makes the two look like one app. Assets are the second build-time fact. Two builds cannot share one hashed-asset namespace behind one domain, since /assets/* already belongs to the account function. The orders build therefore sets its Vite base to /orders-static/, and the shim mounts its static files under the same prefix.
// web/vite.config.ts
// Each vertical ships its own client bundle. Assets must reach the function that built them, so
// the orders build mounts under /orders-static/, which the /orders* CloudFront behavior covers.
const base = process.env.VERTICAL === "orders" ? "/orders-static/" : "/";
// web/index.js
// Static files mount under the same base the client bundle was built with (see vite.config.ts).
const base = vertical === "orders" ? "/orders-static" : "";
app.use(`${base}/assets`, express.static("build/client/assets", { immutable: true, maxAge: "1y" }));
app.use(
base || "/",
express.static("build/client", {
// Prerendered pages: the CDN keeps them 5 min, browsers always revalidate.
setHeaders: (res, path) => {
if (/\.(html|rsc)$/.test(path)) res.setHeader("Cache-Control", "public, max-age=0, s-maxage=300");
// mime-db has no .rsc entry, so send falls back to application/octet-stream, which
// CloudFront does not compress. text/x-component is on its compressible list.
if (path.endsWith(".rsc")) res.setHeader("Content-Type", "text/x-component");
},
}),
);
The CloudFront side is a second origin and two behaviors in front of the existing ones. infra/cloudfront-vertical.sh clones the account origin, then inserts /orders-static/* and /orders* ahead of /assets/* and the default. Order is load-bearing: CloudFront takes the first pattern that matches, and /orders* would also match /orders-static/.
// infra/cloudfront-vertical.sh, the node -e body
const c = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).DistributionConfig;
const clone = (o) => JSON.parse(JSON.stringify(o));
const webfn = c.Origins.Items.find((o) => o.Id === "webfn");
const ordersfn = { ...clone(webfn), Id: "ordersfn", DomainName: process.argv[2] };
c.Origins.Items = [...c.Origins.Items.filter((o) => o.Id !== "ordersfn"), ordersfn];
c.Origins.Quantity = c.Origins.Items.length;
const assets = c.CacheBehaviors.Items.find((b) => b.PathPattern === "/assets/*");
const staticB = { ...clone(assets), PathPattern: "/orders-static/*", TargetOriginId: "ordersfn" };
const pageB = { ...clone(c.DefaultCacheBehavior), PathPattern: "/orders*", TargetOriginId: "ordersfn" };
const keep = c.CacheBehaviors.Items.filter((b) => !["/orders-static/*", "/orders*"].includes(b.PathPattern));
c.CacheBehaviors.Items = [staticB, pageB, ...keep];
c.CacheBehaviors.Quantity = c.CacheBehaviors.Items.length;
process.stdout.write(JSON.stringify(c));
The third fact is that links leave a vertical as document loads. An RSC tree does not cross a function: a client-side navigation from the orders build to / would ask the account function for a payload the orders client cannot render. nav.tsx sets reloadDocument on every link, and the sign-out form on the orders page is a plain <form method="post" action="/logout"> for the same reason.
// web/app/nav.tsx
{pages.map(([to, label]) => (
<NavLink
key={to}
to={to}
end={to === "/"}
// Verticals are separate builds behind one domain; a client-side navigation would ask
// this build's server for another build's tree. Cross-vertical links are document loads.
reloadDocument
className={({ isActive, isPending }) =>
`rounded-md px-3 py-1.5 text-sm ${isActive ? "bg-neutral-900 text-white dark:bg-white dark:text-neutral-900" : "text-neutral-600 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:bg-neutral-800"} ${isPending ? "opacity-60" : ""}`
}
>
{label}
</NavLink>
))}
Permissions follow the split. rsc-mfe-orders-web-role holds dynamodb:Query on the orders table and nothing else beyond logs. rsc-mfe-web-role holds the two Cognito Admin actions on the pool and nothing else beyond logs. infra/web-role.sh attaches each policy only under its own flag, ORDERS_TABLE=1 or COGNITO_ADMIN=1. The account function cannot read orders even if its code tried, and the orders function cannot start a sign-in.
The shell did not change. One tab per vertical was already its shape. After the split, the iOS Orders tab loaded /orders from the orders function with its /orders-static/ assets and no native change. The Android shell was verified on the single-function build and not re-run after the split.
The shim sets an x-vertical header on every response. Observed through CloudFront: GET / from account; GET /orders without a cookie, 302 to /login from orders; POST /login, 302 from account; GET /orders, 200 with three rows from orders; POST /auth/passkey from account while the page that sent it came from orders; POST /logout from account; passkey sign-in; GET /orders, 200 from orders.
One cross-vertical artifact appeared. React Router’s route-discovery request, GET /.manifest?paths=...&version=<build>, is not path-prefixed. The orders client’s manifest call therefore lands on the account function and gets a 204 for a build version it does not know. With document-load links the client did not need it. For a split that keeps client-side navigation across verticals, the documented knob is routeDiscovery in react-router.config.ts: a per-vertical manifestPath, or mode: "initial" to turn discovery off.
The two verticals share exactly the boundary: the domain, the Cognito pool and its aws-jwt-verify verifier, the User-Agent rule for chrome, and the well-known files. The session cookie reaches both functions, both trust the same token, and the well-known files are served by whichever function owns the default behavior.
| Cost of the split | What it means in this build |
|---|---|
| Cross-vertical navigation | A full document load on every link that crosses a vertical |
| Shared chrome | A shared package at build time; each vertical renders its own instance |
| Deployment | Two functions, two roles, one distribution change |
Where the Boundary Moves#
The nine entries below pair a symptom with its cause and fix; they are properties of the pattern, so they recur in other builds of it.
- Hydrated form POSTs return 400 while raw POSTs return 302. React Router 8.5 runs a CSRF check (
throwIfPotentialCSRFAttackin the 8.5.1 build output) before UI-route actions and compares theOriginheader withrequest.url. Resource routes are exempt, so/auth/passkeyrelies onSameSite=Laxplus a JSON body. Behind Lambda Web Functions the Node process seeshttp://and the endpoint hostname, so a real browser fails closed andcurlpasses. The fix iscreateRequestListener(build.fetch, { host: PUBLIC_HOST, protocol: "https:" })from@remix-run/node-fetch-server; the documented alternative isallowedActionOriginsinreact-router.config.ts, which lists accepted origins without overriding the host. With the override, browser POSTs sent straight to the endpoint hostname fail the check. Both web function endpoints in the trial areApplicationManaged, so their own domains stay reachable.IamAuthwith a CloudFront origin access control is what makes CloudFront the only unsigned entry, as the predecessor post covers. This is the one place the single-surface claim touches the framework: the server has to know its public name. - The passkey sheet never appears on iOS. The promise rejects with
NotAllowedError. The associated domain is missing, the AASA file is not served, or it is served without the Team ID prefix. Fetch the file at the public domain first. - The same on Android, with no
CredentialManagerline in logcat. The WebView setting is off, which is the default. With the setting on but asset links wrong, the line appears and the sheet errors instead. - The second tab opens scrolled to the bottom.
display: "none"on the inactive WebView. Keep it laid out and hide it with opacity. - The home page shows the web nav inside the shell. A prerendered
index.htmlwas served by the static middleware before the loader ran. Drop the prerender for routes whose chrome depends on the client. - The session ends after an hour. By design in the demo: the access token’s lifetime with no refresh. The upgrade path is in the session section.
- Passkey registration reports that the device has no provider (Android). No credential provider is installed or signed in. Google Password Manager needs a Google account, so the device state is the cause.
- A private Lambda behind the page server, invoked synchronously. It keeps the service off the internet and still matches the Lambda guide’s anti-pattern entry. The cost is paid twice while one function waits on the other, and failure modes chain. Own the data in the vertical, put a private interface in front of a real service, or go asynchronous.
- A public Cognito client ID treated as a secret.
InitiateAuthis unauthenticated; keeping the client ID on the server hides the entry point and nothing more. A confidential client plus the Admin sign-in operations under IAM is the boundary.
When a Native API Client Wins#
The three alternatives from the opening each have a case where they win; the questions below are how to find it.
| Question | Points at this pattern | Points away |
|---|---|---|
| Who else calls the backend? | Only the product’s own clients | Partners or a public API; keep an API, and the web app can still be RSC |
| Does the mobile app need offline writes? | No | Yes; a native client with a sync layer |
| How much of the mobile UI must be native? | The frame and a few screens; the shell can host native screens next to WebViews | Most screens; a native app with an API |
| Where does identity live? | One IdP with WebAuthn (Cognito, Auth0, Entra); the ceremony runs server-side | Social or SMS OTP flows use the same mechanism; this rarely points away |
| What is the store-review posture? | Native navigation, push, and at least one native capability | A bare WebView may fall under Apple’s guideline 4.2 on minimum functionality; the trial carries no review outcome |
Gained: one auth implementation and one authorization point. Each vertical has one IAM permission to its data, and no function calls another. No token sits on the device, passkeys work once for both clients, and one deploy updates web and mobile content. Paid: the web function is a chokepoint, since every mobile screen is a server render over the network, and per-client chrome costs the prerender. The shell still needs platform attestation files and a patched WebView on Android, and session lifetime is whatever the cookie says. The trial shell has no push registration, deep links into tabs, offline reads, or native screens next to the WebView tabs. A product build adds native code for each; the first three do not move the boundary, because none needs an API the web function does not already front.
Where the Default Holds#
The default holds when the backend has no consumer besides the product’s own clients, the mobile UI is a frame around server-rendered screens, and identity lives in one provider with WebAuthn. It gives way to a native API client when partners consume the backend, the app needs offline writes, or most screens must be native. Inside the default, the boundary moves where the trial hit it. Those places are platform attestation on both sides, the CSRF origin check behind a TLS-terminating proxy, and the prerender lost to per-client chrome. On a new build, check the two well-known files at the public domain first; every other step depends on them.
References#
- Lambda Web Functions runtimes (opens in new tab) - How a web function’s process listens on
AWS_LAMBDA_HTTP_ENDPOINT - Lambda event-driven architectures (opens in new tab) - The “Anti-patterns in Lambda-based event-driven applications” section, which names functions calling functions and the durable-functions exception
- Cognito authentication flows and the USER_AUTH choice-based flow (opens in new tab) -
USER_AUTHwithPREFERRED_CHALLENGEand how passkeys fit as a first factor - Cognito API reference: AdminInitiateAuth (opens in new tab) -
USER_AUTH,PREFERRED_CHALLENGE,SECRET_HASH, and theCREDENTIAL_REQUEST_OPTIONSchallenge parameter - Cognito API reference: AdminRespondToAuthChallenge (opens in new tab) - The
WEB_AUTHNchallenge, itsCREDENTIALresponse, andSECRET_HASH - Cognito API reference: InitiateAuth (opens in new tab) - The public, unauthenticated counterpart the first cut used
- Cognito SDK authorization models (opens in new tab) - Public versus server-side operations, and why the Admin pair needs IAM credentials
- aws-jwt-verify (opens in new tab) - Verifying the Cognito access token from the cookie on every request
- AWS Prescriptive Guidance, micro-frontend composition approaches (opens in new tab) - Server-side composition, the sibling pattern of the vertical split here
- React Router React Server Components (opens in new tab) - RSC Framework Mode, resource routes, and
shouldRevalidate - @remix-run/node-fetch-server (opens in new tab) - The
hostandprotocoloptions ofcreateRequestListenerthat fix the CSRF origin check behind a proxy (0.13; 1.x addstrustProxy) - Supporting associated domains (opens in new tab) - The
webcredentialsservice and the AASA file - Associated Domains Entitlement (opens in new tab) - When
?mode=developerapplies: developer-mode devices, development-profile-signed apps, user opt-in - Supporting passkeys (opens in new tab) - How Apple’s platform authenticator binds a passkey to a relying party, and the iOS 16 floor for passkeys in a WKWebView
- App Review Guidelines, 4.2 Minimum Functionality (opens in new tab) - The guideline text behind the store-review row of the decision table
- WebSettingsCompat reference (opens in new tab) -
setWebAuthenticationSupportandWEB_AUTHENTICATION_SUPPORT_FOR_APP - Credential Manager, passkeys in WebView (opens in new tab) - The WebView passkey guide and its Digital Asset Links requirement
- Credential Manager prerequisites (opens in new tab) - The
get_login_credsandhandle_all_urlsrelations inassetlinks.json - react-native-webview reference (opens in new tab) -
applicationNameForUserAgent,sharedCookiesEnabled,allowsBackForwardNavigationGestures, andonShouldStartLoadWithRequest - react-native-safe-area-context (opens in new tab) -
SafeAreaProviderandSafeAreaViewwithedges, which keep the WebView below the status bar on both platforms - SimpleWebAuthn browser package (opens in new tab) -
startRegistrationandstartAuthenticationwithoptionsJSON - Hotwire Native (opens in new tab) - The native-frame, web-content shape the shell follows
- AWS Prescriptive Guidance, synchronous communication between microservices (opens in new tab) - The counterweight to the anti-pattern entry: synchronous calls as the widely accepted standard, with predictable flow control and strong consistency among the benefits
- ayhansipahi/rsc-mfe-demo (opens in new tab) - The web functions, the orders table scripts, the Cognito and CloudFront scripts, the shell, and the WebView patch
Related posts
A React Router RSC app on Lambda Web Functions behind CloudFront: the entry shim, NODE_ENV, streaming, request-scoped state, partial rendering, and the origin hop.
lambda · serverless · react +3
Before building an internal service layer, decide whether you need one: what it costs per call, the volume where VPC Lattice wins, and when direct invoke still beats it.
aws · aws-cdk · lambda +4
A private REST API structurally cannot carry gRPC, and every AWS surface that speaks gRPC excludes Lambda targets. What to keep from gRPC, and what to drop.
aws · aws-cdk · lambda +4
The private REST API, the resource policy that switches it on, per-route AWS_IAM grants, the two CDK stacks, and signing the call from a Node 22 Lambda.
aws · aws-cdk · lambda +4
SigV4 proves which service is calling and nothing about which user it is for. How to propagate a verified subject, and what the transport actually encrypts.
aws · aws-cdk · lambda +4