Sign-in in installed PWAs
Published Updated
OAuth callbacks can stay within the manifest scope, and a password form can provide a fallback when conditional passkey mediation is unavailable.
Browser and platform support
Section titled “Browser and platform support”RFC 10017 defines OAuth requirements for browser-based applications. MDN marks
PublicKeyCredential.isConditionalMediationAvailable() as available only in secure contexts and
as widely available across browsers since October 2023. The manifest scope member defines which
URLs belong to the installed experience; MDN notes that browsers allow out-of-scope navigation but
may show the URL and browser controls.
Configure the callback path
Section titled “Configure the callback path”RFC 10017 requires browser-based public clients to use the Authorization Code grant with PKCE. It
also requires clients to register redirect URIs and use exact registered redirect URIs. For an app
under /app/, place its callback under that path when it should remain in the manifest scope:
{ "name": "Field Notes", "start_url": "/app/", "scope": "/app/", "display": "standalone"}Register https://example.com/app/oauth/callback with the authorization server. A callback at
https://example.com/oauth/callback is outside this example’s manifest scope, although the browser
does not block that navigation.
Examples
Section titled “Examples”The OAuth example delegates construction of the authorization request and storage of the PKCE verifier to the application server. The passkey example preserves the form as its fallback.
Start an OAuth redirect
Section titled “Start an OAuth redirect”The button requests an authorization URL from the application server, then navigates the current top-level window. The error branch restores the button and leaves the password form in place.
<button id="oauth" type="button">Continue with identity provider</button><p id="oauth-status" role="status"></p>
<script type="module"> const button = document.querySelector('#oauth'); const status = document.querySelector('#oauth-status');
button.addEventListener('click', async () => { button.disabled = true; try { const response = await fetch('/app/api/oauth/start', { method: 'POST' }); if (!response.ok) throw new Error(`OAuth start failed: ${response.status}`); const { authorizationUrl } = await response.json(); location.assign(authorizationUrl); } catch (error) { status.textContent = 'Use email and password to sign in.'; button.disabled = false; } });</script>After the redirect returns to /app/oauth/callback, process the authorization response using the
same transaction that created the PKCE verifier.
Detect conditional passkey support
Section titled “Detect conditional passkey support”The web.dev passkey autofill guide adds the webauthn token to the username field and calls
navigator.credentials.get() with mediation: 'conditional'. Run MDN’s feature
checks first and keep the ordinary form enabled if support is missing or a call fails.
<form action="/app/session" method="post"> <label for="username">Email</label> <input id="username" name="username" autocomplete="username webauthn" required> <label for="password">Password</label> <input id="password" name="password" type="password" autocomplete="current-password"> <button>Sign in</button></form>
<script type="module"> async function offerPasskey() { if (!('PublicKeyCredential' in window) || !('credentials' in navigator) || !PublicKeyCredential.isConditionalMediationAvailable || !PublicKeyCredential.parseRequestOptionsFromJSON || !PublicKeyCredential.prototype.toJSON) { return; // Keep the password form as the fallback. }
if (!(await PublicKeyCredential.isConditionalMediationAvailable())) { return; // Keep the password form as the fallback. }
const response = await fetch('/app/api/passkey/options'); if (!response.ok) return; const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON( await response.json(), ); const credential = await navigator.credentials.get({ publicKey, mediation: 'conditional', }); if (!credential) return;
const verification = await fetch('/app/api/passkey/verify', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(credential.toJSON()), }); if (verification.ok) location.assign('/app/'); }
offerPasskey().catch(() => { // Keep the password form enabled if the conditional request rejects. });</script>The explicit capability test and each early return keep the password form as the runtime fallback.
The server supplies the WebAuthn request options and verifies the returned credential. MDN documents
the JSON conversion methods used above and says the availability check may reject with
SecurityError when the relying-party domain is invalid.
Failure modes
Section titled “Failure modes”- A redirect URI that differs from the registered value violates RFC 10017’s exact-match rule.
- A callback outside manifest
scoperemains navigable, but MDN says browsers may add URL or navigation controls for the out-of-scope page. - A missing or false conditional-mediation check means this example uses the password form.
- A successful availability check does not select a credential; it only reports whether conditional mediation is available, as defined by MDN.