This is the final part of FieldKit, a series where I built one real Progressive Web App and used it to dig into what modern PWAs can actually do. FieldKit is a field-notes app — open source (on GitHub). It works offline, installs, captures media, geotags notes, imports/exports/shares, and sends push. For the finale: the two most "native-grade" capabilities — biometric auth with passkeys, and payments — plus what the whole journey taught me.
The trust tier
The capabilities in this last part are different in kind from the rest. Offline, camera, geolocation — those make an app capable. Passkeys and payments make it trusted: they involve identity and money, the two things users are rightly most protective of. The web can do both, but the honesty gradient is steep — one is genuinely excellent now, the other is still awkward. Let's take them in that order.
Passkeys: killing the password with WebAuthn
FieldKit keeps field notes that might be sensitive, so it should be lockable.
The old answer was a password. The modern answer is a passkey — a cryptographic credential unlocked by the device's biometric (Face ID, fingerprint) or PIN, via the WebAuthn API. No password to phish, reuse, or leak.
Registering a passkey is navigator.credentials.create():
const cred = await navigator.credentials.create({
publicKey: {
challenge: randomBytes(32), // MUST come from your server in production
rp: { name: "FieldKit" },
user: { id: randomBytes(16), name: "field-user", displayName: "Field User" },
pubKeyCredParams: [
{ type: "public-key", alg: -7 }, // ES256
{ type: "public-key", alg: -257 }, // RS256
],
authenticatorSelection: {
userVerification: "preferred", // triggers biometric / PIN
residentKey: "preferred",
},
attestation: "none",
},
});
The browser hands back a public/private key pair. The private key never leaves the device's secure hardware; you keep the public key (on your server, normally). Unlocking later is navigator.credentials.get():
const assertion = await navigator.credentials.get({
publicKey: {
challenge: randomBytes(32), // MUST come from your server in production
allowCredentials: [{ type: "public-key", id: b64ToBuf(storedId) }],
userVerification: "preferred",
},
});
That call is what triggers Face ID / the fingerprint prompt. In FieldKit, passing it gates the note feed — the 🔒 button registers a passkey and locks, and on next open the notes stay out of the DOM until you authenticate.
The honest part: this demo is client-only
Here's what tutorials gloss over. WebAuthn's security lives on the server. In production:
- the challenge must be generated server-side and be single-use, or the flow is replayable;
- on registration, the server stores the public key;
- on login, the server verifies the assertion's signature against that stored key.
FieldKit has no backend, so it generates the challenge locally and treats a returned assertion as success. That's fine for a local device-lock demo — the biometric still guards access — but it is not authentication you'd trust for a real account. If you take one thing from this section: passkeys are a client and server protocol; the browser half is only half. Libraries like SimpleWebAuthn handle the server verification properly.
Payments: the honest one
I'll be straight, because the theme of this series is honesty over hype: the Payment Request API is the weakest capability I've covered, and it doesn't fit FieldKit. I'm including it because "the web can take payments" is a real capability people ask about — but the reality has caveats.
The API itself is elegant. You describe what you're selling and let the browser present a native payment sheet:
const request = new PaymentRequest(
[{ supportedMethods: "https://google.com/pay" /* or Apple Pay, etc. */ }],
{
total: { label: "FieldKit Premium", amount: { currency: "USD", value: "4.99" } },
}
);
if (await request.canMakePayment()) {
const response = await request.show();
// send response to your payment processor to actually charge…
await response.complete("success");
}
Clean — but look at what it doesn't do, and this is the crux:
- It doesn't process the payment. It only collects payment details. You still need a real payment processor (Stripe, Braintree, a Google/Apple Pay merchant setup) and server-side code to charge the card. The API is a nicer form, not a payment backend.
-
basic-cardis gone. The simple built-in card method was removed from Chrome, so there's no zero-setup path anymore — you must integrate a real payment method, each with its own merchant onboarding. - Support is uneven and, for a notes app, the whole thing is contrived. That's why FieldKit ships the passkey lock but not a payment flow — bolting a "Buy Premium" button onto a field journal just to demo an API would be exactly the kind of forced feature this series has avoided.
The right takeaway: if you're building commerce, use your payment provider's SDK (which may use Payment Request under the hood) and keep the charging on your server. Reach for the raw Payment Request API only when you specifically want the browser's native payment sheet and already have a processor behind it.
Honest support picture
- WebAuthn / passkeys: broadly supported now — Chromium, Safari, and Firefox, across desktop and mobile, backed by platform authenticators (Face ID, Touch ID, Windows Hello, Android biometrics). This is production-ready — provided you do the server-side verification. Secure context required.
-
Payment Request API: available in Chromium and Safari, less so in Firefox, but real usefulness depends on configured payment methods (Google Pay / Apple Pay / processor), not just the API.
basic-cardis removed. Treat it as a UI layer over a real payment integration. - Both require a secure context and user-gesture entry points.
Verify on caniuse: WebAuthn and Payment Request before committing.
How this compares to Electron
- Passkeys: Electron can use WebAuthn too (it's Chromium), but a desktop app more often leans on OS credential stores or its own auth. The PWA's win is reach — the same passkey flow runs on the user's phone with Face ID and their laptop with Windows Hello, no platform-specific code.
- Payments: neither Electron nor a PWA "does payments" itself; both ultimately call a processor. Electron can bundle native SDKs; the PWA leans on web payment methods and your server. It's a wash — the processor is the real work in both.
For this trust tier, the platforms converge more than they diverge, because the hard parts (verifying identity, charging money) live on a server either way. What the PWA keeps is its through-line: one codebase, every device, no install friction.
What building FieldKit taught me
Seven parts, one app, and a consistent lesson: the gap between "web app" and "native app" is far smaller than most developers think — and it closes with a manifest, a service worker, and a handful of well-chosen APIs. FieldKit captures media, knows where it is, works with no signal, installs to the home screen, notifies you when it's closed, moves real files, and locks behind your fingerprint. That was science fiction for the web not long ago.
But the other half of the lesson is the one I tried to hold to in every part: support is uneven, and honesty about it is the whole job. Background Sync is Chromium-only. File System Access is Chromium-only. iOS gates push and share-target behind installation. Payments needs a processor. A capable web developer isn't one who knows the happy path — it's one who knows exactly where each API breaks and has the fallback ready. Build for the weaker guarantee, enhance toward the stronger one, and tell your users the truth.
If you've followed along, thank you. The whole app is MIT-licensed on GitHub — fork it, break it, send PRs. And if there's a capability you wish I'd covered, open an issue; the series may be done, but the app doesn't have to be.
git clone https://github.com/JohnJunior/FieldKit.git
cd FieldKit
npx serve .


Top comments (0)