Passkeys for SPAs and mobile apps
"Can my SPA or mobile app use passkeys without me running an auth backend?" Yes. PasskeyBase is the backend. Your client app gets a five-line integration and you never write a line of auth code yourself. This page explains exactly how that works and what — if anything — you still need on the server side.
The short answer
- Pure SPA (React / Vue / Svelte / vanilla JS) — yes, zero backend code.The OAuth 2.0 Authorization Code flow with PKCE was designed exactly for browser apps that can't keep a client secret. Your
client_idis public; the security comes from the dynamic per-login PKCE challenge, not from a shared secret. - Native mobile (iOS / Android) — yes, zero backend code. Same flow, driven through the system browser (
ASWebAuthenticationSession/ Custom Tabs) so the user's keychain is available and your app never embeds a webview. - If you have an API server — minimal code. One middleware that verifies a JWT signature against PasskeyBase's JWKS endpoint. Stateless. No session table. No user database lookup at the auth layer (unless you want one).
- If you have no API at all — even simpler. Use the user info from the ID token client-side for UX. Treat anything client-side as untrusted (always), gate sensitive operations behind PasskeyBase or third-party APIs that verify the access token themselves.
Why this works without a client secret
OAuth 2.0 originally used a client_secret to authenticate the application itself when exchanging an authorization code for tokens. That works for a server-side web app where the secret can stay on the server, but it's structurally impossible for a SPA or a mobile app — anything you ship to the user's device can be inspected.
PKCE (Proof Key for Code Exchange, RFC 7636) replaces the static secret with a dynamic one generated fresh per login attempt:
- The SDK generates a random
code_verifierand its SHA-256 hash, thecode_challenge. - The challenge goes to PasskeyBase with the authorization request. PasskeyBase remembers it bound to the auth code it issues.
- When the SPA redeems the auth code for tokens, it sends the original verifier. PasskeyBase recomputes the hash and confirms it matches.
A network attacker who intercepts the authorization code can't redeem it — they'd need the verifier, which never left the user's browser. This is the IETF + W3Crecommended flow for public clients as of RFC 8252 (native apps) and the OAuth 2.1 draft (everywhere). PasskeyBase requires it for SPA / mobile clients.
The full SPA integration
// Your entire auth integration. That's the whole file.
import { PasskeyBase } from '@passkeybase/js';
const pb = new PasskeyBase({
clientId: 'pk_live_abc123',
domain: 'myapp.auth.ing',
redirectUri: window.location.origin + '/callback',
});
// Sign in
document.getElementById('login').onclick = () => pb.loginWithRedirect();
// Handle callback
if (window.location.pathname === '/callback') {
const { accessToken, idToken, user } = await pb.handleRedirectCallback();
// user.sub, user.email — straight from the ID token
}
// Sign out
document.getElementById('logout').onclick = () => pb.logout();That's it. Drop a button, wire it up, you're done. Works in plain HTML, React, Vue, Svelte, Solid, anywhere. The framework wrappers (@passkeybase/react, @passkeybase/vue, etc.) just add hooks and components on top of this same SDK.
The full mobile integration
// iOS — Swift
import PasskeyBase
let pb = PasskeyBase(
clientId: "pk_live_abc123",
domain: "myapp.auth.ing",
redirectUri: URL(string: "myapp://callback")!
)
// Triggers ASWebAuthenticationSession — system browser, biometric prompt
let session = try await pb.signIn()
print(session.accessToken)Android (Kotlin) is structurally identical via Custom Tabs. The crucial detail: use the system browser, not an embedded webview. iCloud Keychain and Google Password Manager will not release passkeys to a webview — that's an OS-level anti-phishing measure, and PasskeyBase's mobile SDKs handle the system-browser dance for you.
"But I do have an API…"
Almost everyone does. Here's the entire auth surface on the API side: verify a JWT against PasskeyBase's public JWKS. No session table, no user DB, no shared secrets, no calls back to PasskeyBase per request. The verification is local, fast, and cacheable.
// Your API (Node, Go, Rust, anything) verifies the JWT statelessly.
// No session table, no user DB lookup at the auth layer.
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://myapp.auth.ing/.well-known/jwks.json')
);
app.use(async (req, res, next) => {
const auth = req.header('Authorization')?.replace('Bearer ', '');
if (!auth) return res.status(401).end();
const { payload } = await jwtVerify(auth, JWKS, {
issuer: 'https://myapp.auth.ing',
audience: 'pk_live_abc123',
});
req.userId = payload.sub;
next();
});The same middleware exists in every backend SDK (Node, Python,Go, Rust,Java, etc.) — the principle is the same: verify the JWT signature, trust the claims.
"But I have no API…"
Pure-static SPA, no backend whatsoever. Common case for marketing landing pages with a "members area," dashboards that talk only to third-party APIs, side projects, etc. The pattern:
- Render-time gating — call
pb.getUser()client-side. If it returns null, render the signed-out UI; otherwise render the signed-in UI. This is UX, not security — the user can edit your JS and pretend to be signed in. That's fine; there's no protected resource on your side anyway. - Calling third-party APIs as the user — pass the access token in the Authorization header. The third-party API verifies it the same way the JWT middleware above does.
- Storing user data — use a backend-as-a-service (Supabase, Firebase, etc.) and have it accept PasskeyBase JWTs as the auth source. Most of them support external JWT issuers via JWKS configuration.
What still has to live somewhere
To be fully honest about the architecture: WebAuthn fundamentally requires a "relying party server" — something that registers credentials, stores public keys, signs challenges, and verifies assertions. That server exists; it's PasskeyBase. What's true is that you, the application developer, don't have to write, operate, or scale it. It runs on{tenant}.auth.ing and your app talks to it over OAuth.
If your security or compliance posture ever requires that you control the relying-party server yourself (some financial / government deployments do), the same SDK works against a self-hosted Express deployment. That's an enterprise-tier story —talk to us.
Common gotchas
- Embedded webviews break passkeys. Don't use
WKWebView/WebViewfor the auth screen on mobile. UseASWebAuthenticationSession/ Custom Tabs (the SDK does this for you). Webviews don't have access to the user's system passkey store and the browser will refuse to surface platform passkeys. - Don't store tokens in localStorage if XSS is a realistic threat model. The SDK defaults to in-memory storage with refresh-via-cookie. The
localStoragemode is opt-in and documented. - Verify the JWT signature; don't decode and trust. Common mistake. Always verify against the JWKS, never just
JSON.parse(atob(token.split('.')[1])). - Pin your
client_idat the API layer. Verifyaudmatches your client. Otherwise a token issued for a different app on the same tenant could be replayed.
The TL;DR
SPAs and mobile apps don't need a backend to ship passkey auth. They need arelying party, which they get from PasskeyBase. PKCE is the standardized, auditable, IETF-blessed way to make this safe without a client secret. If you do have an API, the auth surface there is one piece of middleware. If you don't, you don't.