Public Key vs Symmetric Credential Types in WebAuthn
Choosing the right cryptographic credential model determines your exposure to phishing, server-side secret theft, and replay attacks. This page contrasts symmetric shared-secret credentials with the asymmetric public key model WebAuthn enforces — covering cryptographic properties, COSE algorithm alignment, storage architecture, server-side verification code, and compliance mapping. For the broader protocol context this topic sits within, see WebAuthn & FIDO2 Protocol Fundamentals.
Cryptographic Models and Spec Grounding
Symmetric credentials authenticate by proving knowledge of a shared secret held by both the client and the relying party (RP). HMAC-based OTP (HOTP/TOTP per RFC 4226/6238), password hashes (bcrypt, Argon2), and pre-shared API keys all follow this model. Because the RP must store the secret value to verify it, server compromise immediately exposes every credential.
Public key credentials (W3C WebAuthn §5.1) authenticate by cryptographic signature. During registration the authenticator generates an asymmetric key pair inside its trust boundary; the private key never leaves. The RP stores only the corresponding public key and a credentialId. During assertion, the authenticator signs authenticatorData || SHA-256(clientDataJSON) with the private key. The RP verifies the signature against the stored public key — no shared secret exists to steal.
The authenticator data byte layout (WebAuthn §6.1) contains the fields the RP must validate before accepting any assertion:
| Byte offset | Field | Relevance to credential type |
|---|---|---|
| 0–31 | rpIdHash |
SHA-256 of the RP ID — binds response to origin |
| 32 | flags byte |
UV bit (bit 2) and UP bit (bit 0) — user verification enforcement |
| 33–36 | signCount |
Monotonic counter for clone detection |
| 37+ | CBOR extensions / AAGUID | Authenticator identity for MDS3 lookup |
Credential property comparison
| Property | Symmetric (shared secret) | Public key (asymmetric) |
|---|---|---|
| Server-side secret storage | Yes — hash or key lives in RP database | No — only non-sensitive public key stored |
| Phishing resistance | Low — OTP and passwords can be relayed in real time | High — signature is origin-bound and challenge-bound |
| Replay attack surface | High — OTP valid for 30–90 s window | None — nonce consumed after single assertion |
| Server compromise impact | All credentials exposed | No credential material exposed |
| NIST AAL alignment | AAL1 (password), AAL2 with MFA (TOTP+password) | AAL2 (UP+UV passkey), AAL3 (hardware-bound, FIPS 140-3) |
| Key distribution | Secure channel required at enrollment | One-way public key export at registration only |
Architecture and Data Flow
The diagram below shows how key material and verification responsibilities are partitioned between the authenticator, browser, and relying party — compared with a symmetric credential flow where the shared secret must pass through every layer.
Implementation Guide
Step 1 — Select COSE algorithms at registration
pubKeyCredParams in PublicKeyCredentialCreationOptions (WebAuthn §5.4) controls which COSE algorithms the RP accepts. The array is ordered by preference; the authenticator picks the first algorithm it supports.
// TypeScript — registration options (server side)
import { generateAuthenticationOptions } from '@simplewebauthn/server';
const creationOptions: PublicKeyCredentialCreationOptionsJSON = {
challenge: base64url(crypto.getRandomValues(new Uint8Array(32))),
rp: { name: 'Acme Corp', id: 'acme.example.com' },
user: {
id: base64url(crypto.getRandomValues(new Uint8Array(16))),
name: '[email protected]',
displayName: 'User',
},
// Ordered preference: ES256 → EdDSA → RS256 fallback
pubKeyCredParams: [
{ type: 'public-key', alg: -7 }, // ES256 — ECDSA P-256 + SHA-256
{ type: 'public-key', alg: -8 }, // EdDSA — Ed25519
{ type: 'public-key', alg: -257 }, // RS256 — RSA PKCS#1 v1.5 + SHA-256
],
authenticatorSelection: {
residentKey: 'required', // discoverable credential (passkey sync)
userVerification: 'required', // UV flag must be set
},
timeout: 60_000,
};
Spec requirement satisfied: W3C WebAuthn §7.1 step 4 — RP must verify that response.getPublicKeyAlgorithm() matches an entry in pubKeyCredParams.
Step 2 — Store the public key and credential metadata
The credential record the RP must persist after successful registration (WebAuthn §7.1 step 22):
-- PostgreSQL credential store
CREATE TABLE webauthn_credentials (
credential_id BYTEA PRIMARY KEY, -- raw bytes, not base64
user_id UUID NOT NULL REFERENCES users(id),
public_key BYTEA NOT NULL, -- COSE_Key CBOR — store raw
cose_algorithm INTEGER NOT NULL, -- -7, -8, or -257
sign_count BIGINT NOT NULL DEFAULT 0, -- monotonic counter
aaguid UUID, -- authenticator model ID for MDS3
transports TEXT[], -- ["internal","hybrid","usb"…]
backed_up BOOLEAN NOT NULL DEFAULT FALSE, -- BE flag from authenticatorData
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ
);
CREATE INDEX ON webauthn_credentials (user_id);
BYTEA is required for credential_id and public_key — there is no COSE_KEY PostgreSQL type. Store the AAGUID from attestedCredentialData (bytes 37–52 of authenticatorData) to enable MDS3 metadata lookups and authenticator-model risk scoring.
Step 3 — Verify the assertion signature
The challenge-response authentication flow requires the server to reconstruct and verify the exact signed payload. WebAuthn §7.2 step 20 defines the concatenation order:
import { createHash, createVerify } from 'node:crypto';
/**
* Verify a WebAuthn assertion signature (WebAuthn §7.2 step 20).
*
* @param publicKeySpki - DER-encoded SubjectPublicKeyInfo (from stored COSE key, converted)
* @param coseAlg - COSE algorithm ID: -7 (ES256), -8 (EdDSA), -257 (RS256)
* @param authenticatorData - raw Buffer from assertion response
* @param clientDataJSON - raw Buffer from assertion response
* @param signature - DER-encoded signature Buffer from assertion response
*/
export function verifyAssertion(
publicKeySpki: Buffer,
coseAlg: -7 | -8 | -257,
authenticatorData: Buffer,
clientDataJSON: Buffer,
signature: Buffer,
): boolean {
// clientDataHash = SHA-256(clientDataJSON) — WebAuthn §7.2 step 19
const clientDataHash = createHash('sha256').update(clientDataJSON).digest();
// signedData = authenticatorData || clientDataHash — §7.2 step 20
const signedData = Buffer.concat([authenticatorData, clientDataHash]);
// Map COSE alg to Node.js crypto algorithm string
const algMap: Record<number, string> = {
[-7]: 'SHA256', // ECDSA P-256
[-8]: 'SHA512', // EdDSA (Node uses SHA-512 internally for Ed25519)
[-257]: 'RSA-SHA256', // RSASSA-PKCS1-v1_5
};
const verifier = createVerify(algMap[coseAlg]);
verifier.update(signedData);
// dsaEncoding: 'der' required — ECDSA signatures from authenticators are DER-encoded
return verifier.verify({ key: publicKeySpki, dsaEncoding: 'der' }, signature);
}
Common mistake: crypto.verify() (one-shot form) takes four arguments — omitting the trailing signature argument causes silent failures in some Node.js versions. Using createVerify() explicitly makes the error surface correctly.
Step 4 — Enforce signCount monotonicity
signCount (bytes 33–36 of authenticatorData, big-endian uint32) is the authenticator’s clone-detection counter. WebAuthn §7.2 step 17 requires the RP to reject an assertion if the stored signCount > 0 and the received value is not strictly greater than the stored value.
function enforceSignCount(
stored: number,
received: number,
credentialId: string,
): void {
if (stored > 0 && received <= stored) {
// Possible authenticator clone — flag and abort
throw new Error(
`signCount regression for credential ${credentialId}: ` +
`stored=${stored}, received=${received}`,
);
}
}
Note: authenticators that sync credentials across devices (passkeys) may present signCount = 0 on every assertion — this is spec-permitted (§7.2 step 17 note). Store 0 and skip the monotonicity check when received === 0.
Validation Checklist
Error Reference Table
| Error / Condition | HTTP status | Trigger | Diagnostic |
|---|---|---|---|
InvalidStateError (DOMException) |
— (client) | authenticatorSelection.residentKey: 'required' but authenticator has no resident key slots |
Check CTAP2 maxCredentialCountInList; offer non-discoverable fallback |
NotSupportedError |
— (client) | Requested COSE alg not supported by authenticator | Ensure -257 (RS256) is last fallback in pubKeyCredParams |
| Signature verification failure | 400 | dsaEncoding mismatch, wrong key format, or wrong signedData construction |
Log raw authenticatorData + clientDataJSON hex; verify hash concatenation order |
signCount regression |
403 | Counter decreased — possible cloned authenticator | Revoke credential; trigger re-enrollment |
rpIdHash mismatch |
400 | Request origin does not match configured RP ID | Confirm rpId matches the effective domain; check subdomain isolation |
| Challenge not found / expired | 400 | Challenge TTL exceeded or already consumed | Enforce 60–120 s TTL; store in Redis with TTL; delete on first use |
credentialId not found |
404 | Credential deleted or user mismatch | Return generic error to avoid user enumeration |
Platform and Library Notes
@simplewebauthn/server (Node.js): verifyAuthenticationResponse() handles COSE key conversion, authenticatorData parsing, and signCount checking automatically. Pass the stored credential’s counter as expectedChallenge context; the library will throw if signCount regresses.
fido2-lib (Node.js): Requires manual CBOR decoding of the attestationObject and explicit COSE-to-SPKI conversion using cbor + @peculiar/webcrypto. More verbose but exposes every spec field for custom validation logic.
py_webauthn (Python): verify_authentication_response() accepts expected_user_verification: UserVerificationRequirement.REQUIRED — ensure you pass this when the registration required UV, or the flags check is skipped.
WebAuthn4J (Java/Kotlin): Strict by default. The WebAuthnAuthenticationManager rejects signCount of 0 unless userVerificationChallengeOption permits it — configure SignCountConstraint.ACCEPT_ZERO_COUNT explicitly for synced passkeys.
iOS Safari (WebKit): Returns signCount = 0 for iCloud Keychain passkeys. Do not interpret this as a clone; update stored counter to 0 and skip monotonicity enforcement.
Windows Hello: Prefers RS256 (alg: -257). List -257 in pubKeyCredParams to avoid NotSupportedError on Windows devices without TPM firmware support for ECDSA P-256.
Android (Chrome/FIDO2 API): Discoverable credentials sync via Google Password Manager. The transports array will include "hybrid" for synced credentials and "internal" for device-bound ones. Store transports to optimise allowCredentials hints on future assertions.
Pitfalls and Security Hardening
-
Storing
publicKeyas base64 text instead of rawBYTEA: Base64 representations of COSE keys introduce encoding ambiguity (padded vs. unpadded, standard vs. URL-safe). Store raw bytes; encode only at the API boundary. -
Accepting any COSE algorithm the authenticator proposes: An authenticator could negotiate a weak algorithm if your
pubKeyCredParamsis too permissive. Always set an explicit allowlist; never acceptalg: 0(no-algorithm placeholder). -
Skipping
rpIdHashvalidation for internal authenticators: Platform authenticators on the same device share the origin; a compromised sub-application could replay assertions if you skip origin binding. -
Treating
signCount = 0as a clone signal: Synced passkeys legitimately emitsignCount = 0. Only raise a clone alert whenstored_sign_count > 0 AND received ≤ stored_sign_count. -
Conflating session tokens with credentials: JWT access tokens and WebAuthn
credentialIdare not interchangeable. ThecredentialIdidentifies the authenticator key pair; the session token grants application access. Confusing the two breaks revocation logic. -
Reusing challenges: Each
PublicKeyCredentialRequestOptionsJSONchallenge must be a fresh CSPRNG value (minimum 16 bytes per WebAuthn §13.1). Reusing a challenge across sessions enables replay after a network interception.
Migration from Symmetric to Public Key Credentials
A production migration preserves symmetric fallbacks only for account recovery and forces passkey enrollment for all active users.
// Dual-mode authentication routing (TypeScript)
async function authenticateUser(
username: string,
webAuthnResponse?: AuthenticationResponseJSON,
password?: string,
): Promise<Session> {
const user = await db.users.findByUsername(username);
if (webAuthnResponse) {
// Primary path: public key verification
const credential = await db.credentials.findById(
webAuthnResponse.id,
);
const verified = await verifyAuthenticationResponse({
response: webAuthnResponse,
expectedChallenge: await challengeStore.consume(username),
expectedOrigin: 'https://acme.example.com',
expectedRPID: 'acme.example.com',
authenticator: {
credentialPublicKey: credential.publicKey,
credentialID: credential.credentialId,
counter: credential.signCount,
},
});
if (!verified.verified) throw new AuthError('WebAuthn assertion failed');
await db.credentials.updateCounter(
credential.credentialId,
verified.authenticationInfo.newCounter,
);
audit.log('AUTH_SUCCESS', { method: 'webauthn', userId: user.id });
return issueSession(user);
}
if (password) {
// Deprecation-phase fallback: symmetric verification
if (!await argon2.verify(user.passwordHash, password)) {
throw new AuthError('Invalid password');
}
audit.log('AUTH_SUCCESS_FALLBACK', { method: 'password', userId: user.id });
// Prompt passkey enrollment on next page load
return issueSession(user, { promptPasskeyEnroll: true });
}
throw new AuthError('No credential provided');
}
Compliance audit checklist
Compliance mappings:
- NIST SP 800-63B AAL2/AAL3 — public key credentials with UV satisfy phishing resistance requirements
- FIPS 140-3 — hardware-backed authenticators with ECDSA P-256 satisfy module security level requirements
- ISO/IEC 27001 Annex A.10 — cryptographic key management controls
- PCI DSS v4.0 Req 8.4 — MFA and phishing-resistant authentication for cardholder data environments
- GDPR Article 32 — data minimisation: no user secret stored server-side
- eIDAS 2.0 — electronic identification assurance level mapping
Related
- When to Use Resident vs Discoverable Credentials — CTAP2.1
residentKeyoptions, authenticator storage limits, and username-less flow requirements - The Challenge-Response Authentication Flow — nonce lifecycle,
clientDataJSONstructure, and origin binding mechanics - Cryptographic Algorithms Supported by WebAuthn — full COSE algorithm registry, curve selection, and algorithm negotiation
- Relying Party and Authenticator Roles — trust boundary partitioning between RP, client, and authenticator
- Validating Attestation Statements on the Server — attestation formats, AAGUID verification, and MDS3 trust anchor configuration