Handling Public Key Storage and Rotation for WebAuthn Credentials
Unlike passwords, WebAuthn’s security model does not depend on keeping the credential material secret on the server side. The relying party stores only the public half of an asymmetric key pair; the private key never leaves the authenticator hardware. This shifts the server-side security boundary from secrecy to integrity: you must store the public key faithfully, validate it against a known algorithm, bind it tightly to a single user account, and be ready to rotate it when the authenticator changes or a policy mandates a new key. For the overall backend verification and secure credential storage architecture this topic sits within, including how these keys are ultimately used during authentication, see the parent section.
Concept Definition and Spec Grounding
What the server actually stores
The W3C WebAuthn Level 2 specification (§6.4.1, “Authenticator Data”) defines authData as a byte array produced by the authenticator during registration. Bytes 55 onward contain the attested credential data, which embeds the credentialPublicKey encoded as a COSE_Key structure (RFC 8152 §7). The COSE_Key uses a CBOR map whose integer keys describe the key type and algorithm:
| COSE map key | Meaning | Example value |
|---|---|---|
1 (kty) |
Key type | 2 (EC2), 3 (RSA) |
3 (alg) |
Algorithm identifier | -7 (ES256), -257 (RS256) |
-1 (crv / n) |
Curve (EC) or modulus (RSA) | 1 (P-256) |
-2 (x / e) |
X coordinate (EC) or exponent (RSA) | 32-byte big-endian |
-3 (y) |
Y coordinate (EC2 only) | 32-byte big-endian |
The credentialId is a separate opaque byte string (§6.4.1, up to 1023 bytes) returned alongside the public key. Crucially, the credentialId is immutable across the credential’s lifetime, while the public key bytes can change if the user registers a replacement authenticator. Your schema must treat these as independent columns.
The signCount (bytes 33–36 of authData, big-endian uint32) establishes the monotonic counter that authentication verification logic uses to detect cloned authenticators. It must be stored and updated on every successful assertion.
COSE algorithm identifiers relevant to rotation
Rotation policies are algorithm-scoped. The algorithms you are likely to encounter and must validate at ingestion:
COSE alg |
Algorithm | Status |
|---|---|---|
-7 |
ES256 (P-256 + SHA-256) | Recommended — NIST-approved |
-8 |
EdDSA (Ed25519) | Recommended — high performance |
-257 |
RS256 (RSA-PKCS1 + SHA-256) | Accepted — legacy authenticators |
-65535 |
RS1 (RSA + SHA-1) | Reject at ingestion |
Never store a credential whose alg value is -65535. Validate the algorithm at ingestion, not at authentication time.
Architecture and Data Flow
The diagram below shows how a public key moves from the authenticator through CBOR parsing, validation, and persistence, and then how rotation replaces it without interrupting active sessions.
Implementation Guide
Step 1 — Parse authData and extract the COSE key (spec: W3C WebAuthn §6.5.4)
The raw authData buffer returned by the authenticator contains a fixed-layout header followed by the attested credential data. Parse it in strict byte-offset order before any database interaction.
import { decode as cborDecode } from 'cbor-x';
interface ParsedAuthData {
rpIdHash: Buffer;
flags: number;
signCount: number;
aaguid: Buffer;
credentialId: Buffer;
coseKey: Map<number, unknown>;
alg: number;
}
function parseAuthData(authData: Buffer): ParsedAuthData {
// Fixed header layout per W3C WebAuthn §6.4.1
const rpIdHash = authData.subarray(0, 32);
const flags = authData[32];
const signCount = authData.readUInt32BE(33);
// Attested credential data starts at byte 37
const aaguid = authData.subarray(37, 53);
const credIdLen = authData.readUInt16BE(53);
const credentialId = authData.subarray(55, 55 + credIdLen);
// COSE_Key is CBOR-encoded from byte 55 + credIdLen onward
const coseKeyBytes = authData.subarray(55 + credIdLen);
const coseKey = cborDecode(coseKeyBytes) as Map<number, unknown>;
const alg = coseKey.get(3) as number;
if (alg === undefined) throw new Error('COSE key missing alg parameter (key 3)');
return { rpIdHash, flags, signCount, aaguid, credentialId, coseKey, alg };
}
Step 2 — Validate the algorithm before any persistence
Reject weak algorithms at ingestion. This prevents a downgrade attack where a compromised authenticator presents a SHA-1-based credential that the server later accepts for authentication.
const ALLOWED_ALGS = new Set([-7, -8, -35, -36, -257, -258, -259]);
const BLOCKED_ALGS = new Set([-65535]); // RS1 = SHA-1
function validateCoseAlg(alg: number): void {
if (BLOCKED_ALGS.has(alg)) {
throw new Error(`Rejected credential: COSE alg ${alg} (RS1/SHA-1) is not permitted`);
}
if (!ALLOWED_ALGS.has(alg)) {
throw new Error(`Rejected credential: unknown COSE alg ${alg}`);
}
}
Step 3 — Persist the credential row atomically (spec: W3C WebAuthn §7.1, step 23)
The spec requires that if a credentialId already exists for a different user, registration must fail. Use an atomic transaction with a unique constraint to enforce this.
-- Schema: production-hardened credential table
CREATE TABLE webauthn_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id BYTEA NOT NULL,
public_key BYTEA NOT NULL,
cose_alg INTEGER NOT NULL,
aaguid UUID,
sign_count BIGINT NOT NULL DEFAULT 0,
is_primary BOOLEAN NOT NULL DEFAULT true,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'rotating', 'archived')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
rotated_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
CONSTRAINT uq_credential_id UNIQUE (credential_id)
);
CREATE INDEX idx_cred_user_primary ON webauthn_credentials (user_id, is_primary)
WHERE status = 'active';
CREATE INDEX idx_cred_id_lookup ON webauthn_credentials USING HASH (credential_id);
async function persistCredential(
db: Pool,
userId: string,
parsed: ParsedAuthData,
rawCoseKeyBytes: Buffer
): Promise<void> {
await db.query('BEGIN');
try {
// Enforce: credentialId must not already exist for any user
const existing = await db.query(
'SELECT user_id FROM webauthn_credentials WHERE credential_id = $1',
[parsed.credentialId]
);
if (existing.rowCount > 0) {
throw new Error('credentialId already registered — possible replay or duplicate');
}
await db.query(
`INSERT INTO webauthn_credentials
(user_id, credential_id, public_key, cose_alg, aaguid, sign_count)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
userId,
parsed.credentialId,
rawCoseKeyBytes, // store raw CBOR bytes, never Base64/PEM
parsed.alg,
parsed.aaguid.length === 16
? aaguidToUuid(parsed.aaguid)
: null,
parsed.signCount
]
);
await db.query('COMMIT');
} catch (err) {
await db.query('ROLLBACK');
throw err;
}
}
function aaguidToUuid(buf: Buffer): string {
const h = buf.toString('hex');
return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20)}`;
}
Step 4 — Update signCount on every successful assertion (spec: W3C WebAuthn §7.2, step 17)
Never skip the counter update. A stale signCount value causes false-positive clone detection on the next authentication.
async function updateSignCount(
db: Pool,
credentialId: Buffer,
newSignCount: number
): Promise<void> {
const result = await db.query(
`UPDATE webauthn_credentials
SET sign_count = $1
WHERE credential_id = $2 AND sign_count < $1`,
[newSignCount, credentialId]
);
if (result.rowCount === 0) {
// Counter did not advance — possible cloned authenticator
throw new Error('signCount regression: potential authenticator clone detected');
}
}
Step 5 — Trigger rotation and manage the dual-key window
Rotation is required when: a device is reported lost or compromised, the cose_alg is deprecated by NIST, an enterprise MDM policy mandates a refresh, or the credential exceeds its maximum lifetime. The safest rotation strategy keeps both keys active until the new key produces its first successful assertion.
async function initiateRotation(db: Pool, userId: string, legacyCredId: Buffer): Promise<void> {
// Mark the existing credential as 'rotating' (not yet revoked)
await db.query(
`UPDATE webauthn_credentials
SET status = 'rotating', is_primary = false, rotated_at = now()
WHERE credential_id = $1 AND user_id = $2`,
[legacyCredId, userId]
);
// The next call to persistCredential() will INSERT the new credential
// with is_primary = false and status = 'active'.
// After the first successful assertion against the new key,
// call promoteNewKey() to finalize the transition.
}
async function promoteNewKey(
db: Pool,
userId: string,
newCredId: Buffer,
legacyCredId: Buffer
): Promise<void> {
await db.query('BEGIN');
try {
await db.query(
`UPDATE webauthn_credentials
SET is_primary = true, status = 'active'
WHERE credential_id = $1 AND user_id = $2`,
[newCredId, userId]
);
await db.query(
`UPDATE webauthn_credentials
SET status = 'archived', revoked_at = now(), is_primary = false
WHERE credential_id = $1 AND user_id = $2`,
[legacyCredId, userId]
);
await db.query('COMMIT');
} catch (err) {
await db.query('ROLLBACK');
throw err;
}
}
Never hard-delete archived credentials immediately. Retain them with revoked_at set for at least the duration mandated by your audit log retention policy (commonly 90 days under SOC 2; longer under HIPAA).
Validation Checklist
Error Reference Table
| Error / condition | HTTP status | Trigger | Diagnostic |
|---|---|---|---|
credentialId already registered |
409 Conflict |
Duplicate registration attempt or replay | SELECT user_id FROM webauthn_credentials WHERE credential_id = $1 |
Blocked COSE alg (-65535) |
400 Bad Request |
RS1/SHA-1 credential presented at registration | Log alg value; reject before database write |
signCount regression |
401 Unauthorized |
Counter value ≤ stored value; possible clone | Alert security team; freeze credential with status = 'suspended' |
CBOR decode failure on authData |
400 Bad Request |
Malformed or truncated attestationObject |
Log raw bytes; check CTAP2 response framing |
| Dual-primary state detected | 500 / alert | Race condition during rotation promoted both keys | SELECT COUNT(*) WHERE user_id=$1 AND is_primary=true AND status='active' > 1 |
Missing aaguid (all zeros) |
n/a (warning) | Self-attestation or platform authenticator | Store as NULL; skip MDS3 lookup |
| Schema constraint violation | 500 Internal Server Error |
NOT NULL or type mismatch on insert |
Review CBOR parsing; ensure alg is mapped to INTEGER not TEXT |
Platform and Library Notes
@simplewebauthn/server (Node.js)
verifyRegistrationResponse() returns a registrationInfo object containing credentialPublicKey as a Uint8Array (raw COSE_Key CBOR) and credentialID as a Uint8Array. Store both as Buffer without re-encoding. The credentialDeviceType field distinguishes platform ('platform') from roaming ('cross-platform') authenticators and should be stored as a metadata column. The library does not persist anything — you own the storage layer entirely.
fido2-lib (Node.js)
Returns the public key as a PEM string by default via result.authnrData.get('credentialPublicKeyPem'). If you want raw COSE bytes (recommended for storage), access result.authnrData.get('credentialPublicKeyCose') instead. Do not store the PEM representation if you intend to validate COSE algorithm IDs directly.
py_webauthn (Python)
verify_registration_response() returns a VerifiedRegistration dataclass. The credential_public_key field is a bytes object containing the raw COSE_Key. The aaguid is returned as a bytes object of length 16 — convert to UUID format before storing if your database uses the UUID type.
WebAuthn4J (Java/Kotlin)
The AuthenticatorData object exposes getAttestedCredentialData().getCOSEKey() as a COSEKey instance. Serialize it back to CBOR bytes using the library’s ObjectConverter before storing, so that your database holds format-agnostic bytes rather than a library-specific Java object.
iOS (Apple Secure Enclave)
Platform credentials on iOS use the Secure Enclave for private key storage. The AAGUID is all zeros (00000000-0000-0000-0000-000000000000) for Apple attestation unless apple attestation format is used, in which case the AAGUID identifies the specific Apple device class. The signCount is always 0 — Apple platform authenticators do not implement the counter. Store 0 and do not flag a counter regression for these credentials.
Android (StrongBox / TEE)
Android credentials backed by StrongBox report a non-zero AAGUID that maps to a specific security key entry in the FIDO MDS3 feed. TEE-backed credentials use a different AAGUID range. Parse and store the AAGUID to distinguish these in fleet reporting. Sign counters are implemented and must be validated.
Windows Hello (TPM / Software)
Windows Hello may return either TPM-backed or software-backed credentials depending on hardware availability. The AAGUID disambiguates these. Software-backed credentials carry lower assurance — consider storing tpm_backed: boolean derived from the MDS3 metadata entry for policy enforcement.
Pitfalls and Security Hardening
1. Storing the public key as Base64 or PEM instead of raw COSE bytes
Root cause: developers treat the key as a string for human readability.
Mitigation: always store the raw CBOR-encoded COSE_Key bytes as BYTEA. The alg field belongs in a separate INTEGER column, not embedded inside a text serialization format. Re-encoding adds a conversion step that can silently introduce encoding bugs during rotation.
2. Skipping algorithm validation at ingestion
Root cause: teams assume the authenticator will only present supported algorithms.
Mitigation: validate cose_alg against an explicit allowlist the moment authData is parsed. An attacker who controls an authenticator firmware could present a weak algorithm; rejection must happen before any database write.
3. Race condition creating dual-primary credentials
Root cause: two concurrent rotation requests both promote their respective new keys before either archives the legacy key.
Mitigation: use a database-level state machine with a CHECK constraint or a unique partial index (UNIQUE WHERE is_primary = true AND status = 'active') to enforce at most one primary credential per user at any time. Wrap both the demote-old and promote-new operations in a single transaction.
4. Hard-deleting archived credentials immediately
Root cause: developers treat revoked_at as the deletion time.
Mitigation: keep archived credentials for the duration of your audit log retention window. Credential IDs are referenced in authentication logs; deleting the row while log entries still exist breaks forensic traceability. Use a scheduled archival job to move rows to cold storage after the retention period.
5. Not handling signCount = 0 for platform authenticators
Root cause: blanket counter-regression checks reject valid assertions from Apple or certain TPM-backed credentials.
Mitigation: when signCount is 0 on both the stored value and the asserted value, treat it as a non-counter credential and skip the regression check. Log a warning but do not reject the authentication. Distinguish this from a genuine regression where the stored value is non-zero and the asserted value is lower.
6. Assuming credentialId uniqueness at the application layer only
Root cause: uniqueness is enforced in a WHERE NOT EXISTS check in application code, which is not safe under concurrent requests.
Mitigation: enforce the UNIQUE constraint on credential_id at the database schema level. Application-level duplicate checks are a defence-in-depth measure, not a replacement for a schema constraint.
Related
- Backend Verification and Secure Credential Storage — parent section covering the full server-side credential lifecycle
- How to Store WebAuthn Public Keys in PostgreSQL — production-tested CBOR normalization, GIN index selection, and atomic rotation transactions for PostgreSQL
- Designing Secure Registration Endpoints — the endpoint that produces the
attestationObjectthis page parses and persists - Implementing Authentication Verification Logic — how the stored public key and
signCountare used during assertion verification - Credential Indexing and Database Schema Design — composite index strategies and high-throughput
credentialIdlookup patterns - Cryptographic Algorithms Supported by WebAuthn — full COSE algorithm registry, curve selection guidance, and algorithm negotiation