Every WebAuthn demo works. Production is where things quietly fall apart.
WebAuthn demos are dangerously convincing.
You follow a tutorial, register a passkey, authenticate successfully, and everything feels… solved.
No passwords. No OTPs. No friction.
Then you ship it.
And suddenly:
- Users authenticate when they shouldn’t
- Counters behave strangely
- Different browsers disagree
- Enterprise customers ask questions your demo never prepared you for
This gap — between “it works” and “it survives production” — is where most WebAuthn implementations fail.
After watching teams repeat the same mistakes for years, three gaps show up again and again.
Gap #1: Demo-Grade Database Access
Most WebAuthn demos include some version of this:
async function getUser(username) {
const query = `SELECT * FROM users WHERE username = '${username}'`;
return db.query(query);
}
It works in development.
It works in testing.
It even works in staging.
Until a real username contains a quote — or someone decides to try ' OR '1'='1.
This isn’t a WebAuthn problem.
It’s a production engineering problem that demos ignore.
Production systems parameterize everything, including dynamic IN clauses:
function buildInClause(values) {
if (!Array.isArray(values) || values.length === 0) {
return { clause: '(NULL)', params: [] };
}
const placeholders = values.map(() => '?').join(',');
return {
clause: `(${placeholders})`,
params: Array.from(values)
};
}
The difference isn’t elegance — it’s survivability.
Gap #2: “One-Call” Verification Illusions
Demo verification often looks like this:
await fido2.verify(response);
Clean. Minimal. Completely insufficient.
In production, this single call hides multiple attack surfaces:
- Replay attacks
- Counter rollback (cloned authenticators)
- Origin spoofing
- Challenge reuse across sessions
- Native app origin edge cases
Real verification logic validates every layer:
if (counter <= prevCounter && counterSupported) {
throw new Error("counter rollback detected");
}
if (origin !== expectedOrigin) {
throw new Error("origin mismatch");
}
Counters, origins, encoding, challenge binding, RP ID — none of these are optional in production.
If you skip one, authentication still “works” — until it doesn’t.
Gap #3: The Myth of “Single-Domain” WebAuthn
Demos assume:
- One RP ID
- One domain
- One policy
Production environments don’t.
Real systems need to handle:
- Multiple subdomains
- Wildcard RP IDs
- Enterprise authenticator allowlists (AAGUID-based)
- Device limits per user
- Per-tenant timeouts
- Device binding
Configuration stops being a constant and becomes data:
{
"domain": ".example.com",
"device_limit": 2,
"registration_session_timeout": 999
}
This isn’t complexity for complexity’s sake.
It’s the minimum required to support real organizations.
What “Production-Ready” Actually Means
| Area | Demo Reality | Production Reality |
|---|---|---|
| Database | String queries | Parameterized everywhere |
| Verification | Single function | Multi-layer validation |
| Domains | localhost | Wildcards & subdomains |
| Counters | Ignored | Rollback detection |
| Policy | Hardcoded | Per-tenant configuration |
None of these failures are obvious in demos.
All of them show up after users trust your system.
The Hard Truth About WebAuthn
WebAuthn is easy to demonstrate.
It is hard to operate safely.
The problem isn’t the standard — it’s the illusion that a passing demo equals a shippable system.
If you’re planning to deploy passkeys beyond a prototype, treat demos as educational tools, not architectural references.
Because in authentication, failure rarely looks like an error.
It looks like success — for the wrong user.
I work on WebAuthn systems that need to survive real-world traffic, browsers, and enterprise constraints.
Most of the lessons above came from fixing things that “worked fine” — until they didn’t.
Top comments (0)