DEV Community

dqj
dqj

Posted on

Why WebAuthn Feels Easy — Until You Try to Ship It

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);
}
Enter fullscreen mode Exit fullscreen mode

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)
  };
}
Enter fullscreen mode Exit fullscreen mode

The difference isn’t elegance — it’s survivability.


Gap #2: “One-Call” Verification Illusions

Demo verification often looks like this:

await fido2.verify(response);
Enter fullscreen mode Exit fullscreen mode

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");
}
Enter fullscreen mode Exit fullscreen mode
if (origin !== expectedOrigin) {
  throw new Error("origin mismatch");
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)