← Blog

React Native WebView: Why Back Replays a Login Ticket

6 min read
Watch the walkthrough on YouTube

The companion video becomes viewable when its YouTube release goes public.

When back opened the login document again

I traced a back-button failure to the WebView history. The native screen opened a small HTML form, posted a one-time ticket and followed a redirect into the website. A later location.replace selected the exact destination, but the earlier form remained behind the first real page.

Pressing back could post that spent ticket again and return 401. Session recovery then landed on the same page. A re-request of a POST-only route could return 405, which the existing handler ignored. The source documents both outcomes; the website redirect chain determines which one occurs.

The session bootstrap belongs to its own instance

I split the flow into bootstrap and browsing phases. The form establishes the website cookie. Once it lands on a real page, sessionUrl becomes the requested URL and changes the React key. React discards the bootstrap WebView and mounts a new browsing WebView directly at that URL.

The first browsing page has no bootstrap document behind it. Ordinary website navigation keeps the same key, so subsequent history remains useful. The needsAuth guard skips this transition for a site opened directly by URL.

The previous hand-off already required a second page fetch to add parameters that the first redirect could not express. The remount replaces that fetch in this integration. Another redirect contract can have a different cost.

The site redirects expired sessions to /app-required. I remember the real page being read and request one fresh ticket. The remount returns to that reading URL.

The discarded landing cannot prove that the cookie survives in the new instance. I clear the renewal guard only after a completed real page in the remounted WebView. A second expiry before that success ends on the error screen. Clearing the guard on every landing could renew forever, spending a new ticket and potentially another native attestation each cycle.

Reviewed integration example

I reviewed this teaching example against the screen and source tests; I did not execute it on a device.

The example uses sample-independent inputs, a same-origin redirect contract and a stable issueCode callback. It adds stale-request protection and a conservative origin allowlist. Those safeguards are teaching additions. Mount the component only while its native navigation screen is focused, or connect its back subscription to your navigation focus lifecycle.

// Reviewed teaching adaptation, not a device-tested implementation.
// Assumes a same-origin POST endpoint that sets a persistent WebView session
// and redirects to a real page. Native cookie survival must be verified.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {ActivityIndicator, BackHandler, Button, Text, View} from 'react-native';
import WebView, {type WebViewNavigation} from 'react-native-webview';

type Props = {
  targetUrl: string; // A trusted HTTPS URL on origin. No credential parameters.
  origin: string;
  needsAuth: boolean;
  issueCode: () => Promise<string>; // Never persist or log the returned ticket.
  onClose: () => void;
};
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, c =>
  ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]!));
export function SessionWebView({targetUrl, origin, needsAuth, issueCode, onClose}: Props) {
  const web = useRef<WebView>(null);
  const back = useRef(false);
  const renewed = useRef(false);
  const reading = useRef(targetUrl);
  const generation = useRef(0);
  const [code, setCode] = useState<string | null>(null);
  const [sessionUrl, setSessionUrl] = useState<string | null>(null);
  const [failed, setFailed] = useState(false);
  const [loading, setLoading] = useState(true);
  const authUrl = `${origin}/mobile/auth`;

  // Additional teaching safety: stale async results cannot replace a newer ticket.
  const bootstrap = useCallback(async () => {
    const request = ++generation.current;
    setLoading(true); setCode(null); setSessionUrl(null); back.current = false;
    try {
      const next = await issueCode();
      if (request === generation.current) setCode(next);
    } catch {
      if (request === generation.current) { setFailed(true); setLoading(false); }
    }
  }, [issueCode]);
  useEffect(() => {
    reading.current = targetUrl; renewed.current = false; setFailed(false);
    if (needsAuth) void bootstrap(); else { setSessionUrl(targetUrl); setLoading(false); }
    return () => { generation.current++; };
  }, [targetUrl, needsAuth, bootstrap]);
  useEffect(() => {
    const sub = BackHandler.addEventListener('hardwareBackPress', () => {
      if (back.current) web.current?.goBack(); else onClose();
      return true;
    });
    return () => sub.remove();
  }, [onClose]); // Mount this component only while its navigation screen is focused.

  const renewOnce = () => {
    if (!needsAuth || renewed.current) { setFailed(true); setLoading(false); return; }
    renewed.current = true; void bootstrap();
  };
  const navigate = (state: WebViewNavigation) => {
    back.current = state.canGoBack;
    let url: URL;
    try { url = new URL(state.url); } catch { return; }
    if (state.loading || url.origin !== origin) return;
    if (url.pathname === '/app-required') { renewOnce(); return; }
    if (url.pathname === '/mobile/auth') return;
    if (needsAuth && sessionUrl === null) {
      const wanted = renewed.current ? reading.current : targetUrl;
      back.current = false; setLoading(true); setSessionUrl(wanted); setCode(null);
      return; // Keep the guard until the new instance proves its cookie.
    }
    reading.current = state.url; renewed.current = false; setLoading(false);
  };
  if (failed) return <View><Text>Session could not be opened.</Text>
    <Button title="Close" onPress={onClose}/></View>;
  if (needsAuth && !code && !sessionUrl) return <ActivityIndicator/>;
  const html = `<form id="auth" method="POST" action="${escapeHtml(authUrl)}">
    <input type="hidden" name="code" value="${escapeHtml(code ?? '')}"/>
    </form><script>document.getElementById('auth').submit();</script>`;
  return <View style={{flex: 1}}>
    <WebView ref={web} key={sessionUrl ?? code ?? 'anonymous'}
      source={sessionUrl ? {uri: sessionUrl} : {html, baseUrl: authUrl}}
      sharedCookiesEnabled incognito={false} allowsBackForwardNavigationGestures
      onNavigationStateChange={navigate}
      onShouldStartLoadWithRequest={request => {
        if (request.url === 'about:blank') return true;
        try { return new URL(request.url).origin === origin; } catch { return false; }
      }}
      onHttpError={event => {
        if (event.nativeEvent.statusCode === 401) renewOnce();
        else if (event.nativeEvent.statusCode >= 500) { setFailed(true); setLoading(false); }
      }}
      onError={() => { setFailed(true); setLoading(false); }}/>
    {loading && <ActivityIndicator/>}
  </View>;
}
// Integration: validate origin/target as HTTPS before mounting. Keep issueCode
// stable with useCallback. Provide a focus lifecycle and an explicit retry screen.
// This conservative origin allowlist is an adaptation; cross-origin flows need
// a reviewed allowlist. Do not treat any arbitrary same-origin landing as proof
// of authorization without checking your server redirect contract.

HttpOnly does not prohibit native sending

The same change corrected a PDF comment. HttpOnly prevents page JavaScript from reading the cookie. A native request can still send cookies from the appropriate store. The existing base64 PDF bridge remains a stopgap while the route's required request context is confirmed. Domain matching, cookie-store synchronization and server authorization remain integration checks.

What the checks establish

The source tests inspect three screen decisions through a stubbed WebView: a second mount on the exact target, restoration of the reading page after renewal, and an error after two failed code requests. The original Jest executable is missing in the available checkout. The separate local model has five passing decision tests; it does not reproduce native history or cookie storage.

The commit explicitly leaves iOS cookie survival across the remount unverified. sharedCookiesEnabled and incognito={false} express the design, but they cannot establish that behavior for a device.

Before release, check the first real page's history, Android hardware back, iOS edge swipe and ordinary back navigation after browsing deeper. Expire the session while reading another page, clear the cookie store between phases, and confirm both destination restoration and the bounded error path. Confirm the installed WebView version for each platform.

The rule

Bootstrap the session separately, then open the real page in a fresh WebView. Back must never reach a spent credential.

Previous lesson: Force Update vs OTA. Link will be added when published. Next lesson: Reanimated worklets and the UI runtime. Link will be added when published.

Sources

The video uses synthetic English narration by macOS Daniel and illustrations with sample hosts and tickets. EN/AR captions accompany the local video package. Upload and public article links are unassigned.

Comments

No account needed.

  1. Loading comments…