Cryptographic Algorithms Supported by WebAuthn

Selecting the wrong algorithm — or negotiating it incorrectly — is one of the most common causes of signature verification failures in production WebAuthn deployments. This page covers every COSE algorithm the WebAuthn & FIDO2 protocol currently mandates or recommends, explains the CBOR key structures involved, and provides TypeScript and Python verification code grounded in W3C WebAuthn Level 3 (§ 6.5) and RFC 8152 (COSE).


Concept Definition and Spec Grounding

WebAuthn does not invent its own cryptographic vocabulary. It borrows COSE — CBOR Object Signing and Encryption (RFC 8152, superseded by RFC 9052) — to express algorithm identifiers as small negative integers inside CBOR maps. During credential creation the Relying Party (RP) sends an ordered pubKeyCredParams array; the authenticator picks the first entry it supports. The chosen algorithm determines how the COSE_Key map inside attestationObject.authData is structured, and it is the algorithm that must be used for every subsequent assertion signature.

COSE key map layout (W3C WebAuthn Level 3 § 6.5.1.1)

The public key is embedded inside authenticatorData starting at byte 55 (after the 32-byte rpIdHash, 1-byte flags, 4-byte signCount, and any AAGUID/credId prefix). The CBOR-encoded COSE_Key contains:

COSE label Key EC2 value RSA value
1 (kty) Key type 2 = EC2 3 = RSA
3 (alg) Algorithm -7 (ES256) / -8 (EdDSA) -257 (RS256) / -37 (PS256)
-1 (crv / n) Curve / modulus 1 = P-256 RSA modulus bytes
-2 (x / e) X coord / exponent 32-byte big-endian RSA exponent bytes
-3 (y) Y coordinate 32-byte big-endian

Core algorithm registry

COSE alg Integer Primitive Mandatory
ES256 -7 ECDSA P-256 + SHA-256 Yes (FIDO2 §5.1)
EdDSA -8 Ed25519 Recommended
RS256 -257 RSA PKCS#1 v1.5 + SHA-256 Optional (enterprise)
PS256 -37 RSA-PSS + SHA-256 Optional
ES384 -35 ECDSA P-384 + SHA-384 Optional
ES512 -36 ECDSA P-521 + SHA-512 Optional

ES256 is the only algorithm all FIDO2-certified authenticators must implement. Every other entry is negotiated; if an authenticator does not support any entry in pubKeyCredParams it throws NotSupportedError.


Architecture and Data Flow

The diagram below shows the algorithm negotiation path from RP to authenticator and back during registration, and the corresponding verification path during authentication.

WebAuthn COSE algorithm negotiation and verification flow Sequence diagram showing how the Relying Party declares pubKeyCredParams, the authenticator selects an algorithm and returns the COSE_Key, and the RP verifies the assertion signature using the stored algorithm. Relying Party Browser / Client Authenticator REGISTRATION challenge + pubKeyCredParams [-7, -8, -257] navigator.credentials.create() selects first supported alg e.g. ES256 (alg -7) attestationObject (COSE_Key + authData) PublicKeyCredential response store publicKey (SPKI) + alg = -7 AUTHENTICATION new challenge (32 random bytes) navigator.credentials.get() sign(authData ‖ SHA-256(cDJ)) using stored private key alg = ES256 AuthenticatorAssertionResponse forward to RP for verification crypto.verify(SHA256, payload, storedSPKI, {dsaEncoding:'der'})

Implementation Guide

Step 1 — Declare pubKeyCredParams with an ordered preference array

W3C WebAuthn Level 3 § 5.4 requires the RP to pass pubKeyCredParams to navigator.credentials.create(). The authenticator selects the first entry it supports, so list stronger or preferred algorithms first.

// registration-options.ts
const pubKeyCredParams: PublicKeyCredentialParameters[] = [
  { type: 'public-key', alg: -7   }, // ES256  — ECDSA P-256 + SHA-256 (mandatory per FIDO2)
  { type: 'public-key', alg: -8   }, // EdDSA  — Ed25519 (modern platform authenticators)
  { type: 'public-key', alg: -257 }, // RS256  — RSA PKCS#1 v1.5 + SHA-256 (enterprise HSMs)
  { type: 'public-key', alg: -37  }, // PS256  — RSA-PSS + SHA-256 (FIPS 140-3 environments)
];

const options: PublicKeyCredentialCreationOptions = {
  challenge: crypto.getRandomValues(new Uint8Array(32)), // server-generated CSPRNG
  rp: { name: 'Example Corp', id: 'example.com' },
  user: {
    id: crypto.getRandomValues(new Uint8Array(16)),
    name: '[email protected]',
    displayName: 'Alice',
  },
  pubKeyCredParams,
  authenticatorSelection: {
    residentKey: 'preferred',       // discoverable credential
    userVerification: 'required',   // UV flag must be set in authData
  },
  timeout: 60_000,
};

Satisfies: W3C WebAuthn Level 3 § 5.4 (PublicKeyCredentialCreationOptions), FIDO2 Security Requirements § 5.1 (ES256 mandatory).

Step 2 — Parse the COSE_Key from authenticatorData

After registration, the RP receives an attestationObject. The public key lives inside authData at a variable byte offset after the attestedCredentialData prefix. Use an audited CBOR library — never hand-roll COSE parsing.

// parse-cose-key.ts
import { decode } from 'cbor-x'; // RFC 7049 / RFC 8949 CBOR decoder

interface CoseKey {
  1: number;  // kty  — 2 = EC2, 3 = RSA, 1 = OKP (EdDSA)
  3: number;  // alg  — -7, -8, -257, -37 …
  '-1': number | Buffer; // crv (EC/OKP) or n (RSA)
  '-2': Buffer;          // x (EC) or e (RSA)
  '-3'?: Buffer;         // y (EC only)
}

function extractCoseKey(authData: Buffer): CoseKey {
  // authData layout (W3C WebAuthn Level 3 § 6.1):
  //   [0..31]  rpIdHash (32 bytes)
  //   [32]     flags (1 byte) — bit 6 = AT (attested credential data present)
  //   [33..36] signCount (4 bytes, big-endian uint32)
  //   [37..52] AAGUID (16 bytes)
  //   [53..54] credIdLength (2 bytes, big-endian uint16)
  //   [55 .. 55+credIdLength-1]  credentialId
  //   [55+credIdLength ..]       COSE_Key (CBOR map)

  const flags = authData[32];
  if (!(flags & 0x40)) throw new Error('AT flag not set — no attested credential data present');

  const credIdLength = authData.readUInt16BE(53);
  const coseStart = 55 + credIdLength;
  return decode(authData.subarray(coseStart)) as CoseKey;
}

Satisfies: W3C WebAuthn Level 3 § 6.5.1.1 (COSE_Key parsing), RFC 9052 § 7 (key type parameters).

Step 3 — Store alg alongside the SPKI-encoded public key

The alg integer is the routing key for all future assertion verifications. Store it in the same row as the credential.

-- credentials table (PostgreSQL)
CREATE TABLE webauthn_credentials (
  credential_id     BYTEA        PRIMARY KEY,
  user_id           UUID         NOT NULL REFERENCES users(id),
  cose_alg          SMALLINT     NOT NULL,     -- -7, -8, -257, -37 …
  public_key_spki   BYTEA        NOT NULL,     -- SubjectPublicKeyInfo (DER)
  sign_count        INTEGER      NOT NULL DEFAULT 0,
  aaguid            UUID,
  created_at        TIMESTAMPTZ  NOT NULL DEFAULT now(),
  last_used_at      TIMESTAMPTZ
);
CREATE INDEX ON webauthn_credentials (user_id);

For background on how stored public keys relate to overall credential storage design, see handling public key storage and rotation.

Step 4 — Route assertion verification by cose_alg

The challenge-response authentication flow requires the RP to verify the assertion signature using the exact algorithm negotiated at registration. Route by the stored cose_alg, not by inspecting the public key’s ASN.1 OID at runtime.

// verify-assertion.ts
import { createVerify } from 'node:crypto';

interface StoredCredential {
  coseAlg: number;          // from DB column cose_alg
  publicKeySpki: Buffer;    // DER-encoded SPKI
  signCount: number;
}

interface AssertionResponse {
  authenticatorData: string; // base64url
  clientDataJSON: string;    // base64url
  signature: string;         // base64url
}

async function verifyAssertion(
  assertion: AssertionResponse,
  stored: StoredCredential,
): Promise<{ verified: boolean; newCounter: number }> {
  const authData   = Buffer.from(assertion.authenticatorData, 'base64url');
  const clientData = Buffer.from(assertion.clientDataJSON,     'base64url');
  const signature  = Buffer.from(assertion.signature,          'base64url');

  // Signed payload per WebAuthn Level 3 § 7.2 step 20:
  // authData ‖ SHA-256(clientDataJSON)
  const clientDataHash  = createHash('sha256').update(clientData).digest();
  const signedPayload   = Buffer.concat([authData, clientDataHash]);

  // Algorithm routing — must use OpenSSL digest names, not COSE labels
  const { digestAlg, dsaEncoding } = resolveAlgParams(stored.coseAlg);

  const ok = createVerify(digestAlg)
    .update(signedPayload)
    .verify({ key: stored.publicKeySpki, format: 'der', type: 'spki', dsaEncoding }, signature);

  if (!ok) throw new Error('Signature verification failed');

  // signCount is bytes 33–36 of authData (big-endian uint32)
  const newCounter = authData.readUInt32BE(33);
  if (newCounter !== 0 && newCounter <= stored.signCount) {
    throw new Error('signCount did not increase — possible cloned authenticator');
  }

  return { verified: true, newCounter };
}

function resolveAlgParams(coseAlg: number): { digestAlg: string; dsaEncoding: 'der' | 'ieee-p1363' } {
  switch (coseAlg) {
    case  -7:  return { digestAlg: 'SHA256',    dsaEncoding: 'der'         }; // ES256
    case  -35: return { digestAlg: 'SHA384',    dsaEncoding: 'der'         }; // ES384
    case  -36: return { digestAlg: 'SHA512',    dsaEncoding: 'der'         }; // ES512
    case  -8:  return { digestAlg: 'SHA512',    dsaEncoding: 'ieee-p1363'  }; // EdDSA / Ed25519
    case -257: return { digestAlg: 'RSA-SHA256', dsaEncoding: 'der'        }; // RS256
    case  -37: return { digestAlg: 'RSA-SHA256', dsaEncoding: 'der'        }; // PS256 (use createVerify with 'RSA-PSS')
    default:   throw new Error(`Unsupported COSE alg: ${coseAlg}`);
  }
}

Satisfies: W3C WebAuthn Level 3 § 7.2 steps 20–21 (signature and signCount validation).

Step 5 — Handle EdDSA (Ed25519) as a special case

Ed25519 keys have kty = 1 (OKP) and crv = 6. Node.js crypto.createVerify('SHA512') does not work for Ed25519; use crypto.verify(null, …) with a key object typed as ed25519.

// ed25519-verify.ts
import { createPublicKey, verify as cryptoVerify } from 'node:crypto';

function verifyEdDSA(signedPayload: Buffer, signature: Buffer, spkiDer: Buffer): boolean {
  const pubKey = createPublicKey({ key: spkiDer, format: 'der', type: 'spki' });
  // Ed25519 uses null as the algorithm parameter — the key type implies the hash function
  return cryptoVerify(null, signedPayload, pubKey, signature);
}

Validation Checklist


Error Reference Table

Error / Condition HTTP status Trigger Diagnostic
NotSupportedError 400 No entry in pubKeyCredParams is supported by the authenticator Add ES256 (-7) as a fallback entry
Signature verification failure 401 Wrong dsaEncoding (raw vs DER) or wrong OpenSSL digest name Log coseAlg and re-check resolveAlgParams
signCount not increasing 401 Cloned authenticator or replay attack Alert security team; optionally reject assertion
CBOR decode error 400 Malformed attestationObject or truncated authData Validate with cbor-x diagnostics mode
Unknown kty in COSE_Key 400 Authenticator returned an unsupported key type (e.g. symmetric) Reject credential; log AAGUID for investigation
RSA key < 2048 bits 400 Non-compliant roaming key or test token Enforce minimum key size in registration validation
EdDSA verify throws ERR_CRYPTO_UNSUPPORTED_OPERATION 500 Node.js < 15 or OpenSSL < 1.1.1 Upgrade runtime; Ed25519 requires Node.js 15+
pubKeyCredParams empty array 400 RP misconfiguration Always include at least ES256

Platform and Library Notes

@simplewebauthn/server (TypeScript)

verifyRegistrationResponse and verifyAuthenticationResponse handle COSE_Key parsing and algorithm routing automatically. To restrict allowed algorithms, pass supportedAlgorithmIDs to generateRegistrationOptions:

import { generateRegistrationOptions } from '@simplewebauthn/server';

const options = await generateRegistrationOptions({
  rpName: 'Example Corp',
  rpID: 'example.com',
  userID: userId,
  userName: '[email protected]',
  supportedAlgorithmIDs: [-7, -8], // ES256 + EdDSA only; exclude RS256
});

py_webauthn (Python)

verify_authentication_response accepts expected_credential_public_key as a CBOR-encoded COSE_Key. The library reads the alg field from the key map and selects the verifier internally — no manual routing needed:

from webauthn import verify_authentication_response

verification = verify_authentication_response(
    credential=assertion,
    expected_challenge=expected_challenge,
    expected_rp_id="example.com",
    expected_origin="https://example.com",
    credential_public_key=stored_credential.cose_public_key,
    credential_current_sign_count=stored_credential.sign_count,
)

WebAuthn4J (Java)

CoreAuthenticationDataValidator uses the COSEKey class hierarchy. ES256 maps to EC2COSEKey, RS256 maps to RSACOSEKey, and EdDSA maps to OKPCOSEKey. Key type mismatches throw VerificationException with a descriptive message.

Platform authenticator quirks

Platform Default alg Notes
iOS Secure Enclave (Safari) ES256 (-7) RSA not supported on-device; PS256 not available
Android StrongBox (Chrome) ES256 (-7) EdDSA supported from Android 14+
Windows Hello TPM 2.0 RS256 (-257) Defaults to RSA; ES256 available when explicitly requested
YubiKey 5 Series ES256 (-7) EdDSA on firmware 5.2.3+; RS256 on all versions
iCloud / Google passkey sync ES256 (-7) Standardised for cross-device interoperability
Legacy U2F tokens ES256 (-7) only Cannot fulfill RS256 or EdDSA entries; NotSupportedError if ES256 is absent

The attestation vs assertion explained page covers how the algorithm choice affects attestation statement formats (packed, tpm, apple) during registration.


Pitfalls and Security Hardening

1. Passing the COSE label string to crypto.verify() instead of the OpenSSL digest name

Root cause: COSE labels like ES256 are not valid OpenSSL algorithm names. Node.js silently accepts the string and returns false for every signature. Mitigation: always route through a resolveAlgParams() function that maps COSE integer → OpenSSL string (SHA256 for ES256, RSA-SHA256 for RS256).

2. Treating ECDSA signatures as raw r || s instead of DER

Root cause: some authenticators (notably some FIDO U2F tokens) return raw 64-byte signatures; others return DER-encoded ASN.1. Using the wrong format produces consistent verification failures. Mitigation: set dsaEncoding: 'der' explicitly and normalise raw signatures to DER before calling verify().

3. Not persisting cose_alg with the credential

Root cause: some implementations assume ES256 universally and do not store the negotiated algorithm. When an enterprise device enrolls with RS256, subsequent authentication silently fails. Mitigation: store cose_alg as a non-nullable column alongside credential_id at registration time; never infer the algorithm from the public key’s ASN.1 OID at verification time.

4. Accepting credentials with an alg not in the RP’s declared pubKeyCredParams

Root cause: the browser may have exercised a platform default that the RP did not explicitly offer. Accepting it bypasses the RP’s algorithm policy. Mitigation: after registration, validate that the returned cose_alg appears in the original pubKeyCredParams array before storing the credential.

5. Including RS1 (COSE alg -65535, SHA-1) as a legacy fallback

Root cause: some old enterprise integrations include SHA-1-based RSA to support aging tokens. SHA-1 collision attacks (SHAttered, 2017) make this unacceptable for identity assertions. Mitigation: maintain an explicit denylist ([-65535]) in the RP’s algorithm policy and reject any credential presenting this identifier.

6. Using a single crypto.createVerify() call for both ECDSA and EdDSA

Root cause: Ed25519 does not use a separate digest step — createVerify('SHA512') does not work; you must call crypto.verify(null, …) with an Ed25519 key object. Mitigation: branch on coseAlg === -8 before verification and use the correct API path.


Related