Why WebAuthn Feels Easy — Until You Try to Ship It

Published: (January 8, 2026 at 08:14 AM EST)
3 min read
Source: Dev.to

Source: Dev.to

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, testing, and even staging—until a real username contains a quote or someone tries ' 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, but 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

AreaDemo RealityProduction Reality
DatabaseString queriesParameterized everywhere
VerificationSingle functionMulti‑layer validation
DomainslocalhostWildcards & subdomains
CountersIgnoredRollback detection
PolicyHardcodedPer‑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. 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.

Back to Blog

Related posts

Read more »