Skip to content

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.

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.

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.

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.

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.

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.

  • A redirect URI that differs from the registered value violates RFC 10017’s exact-match rule.
  • A callback outside manifest scope remains 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.