How to Store WebAuthn Public Keys in PostgreSQL
Implementing passkey-based authentication requires precise handling of cryptographic material at the persistence layer. WebAuthn registration payloads deliver public keys in CBOR-encoded COSE format, which cannot be safely stored using standard string types. This guide details the exact schema architecture, diagnostic workflows, and secure remediation steps required to store WebAuthn credentials in PostgreSQL without introducing encoding drift, verification failures, or compliance gaps. Proper implementation aligns directly with enterprise-grade Backend Verification & Secure Credential Storage workflows.
1. COSE Key Architecture & PostgreSQL Type Mapping
WebAuthn authenticators return public keys as binary COSE (CBOR Object Signing and Encryption) structures. These payloads contain algorithm identifiers, key parameters (e.g., x, y coordinates for EC2, or x for OKP), and metadata required for cryptographic verification. PostgreSQL must preserve these bytes exactly as transmitted to prevent signature validation failures downstream.
Baseline Schema Architecture
Store the raw COSE key in a BYTEA column. Never convert to Base64, PEM, or DER before persistence, as transformation introduces unnecessary compute overhead and potential data loss during round-trips.
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,
alg INTEGER NOT NULL,
transports TEXT[],
created_at TIMESTAMPTZ DEFAULT NOW(),
last_used_at TIMESTAMPTZ
);
Diagnostic Validation:
-- Verify column types and encoding constraints
SELECT column_name, data_type, character_maximum_length
FROM information_schema.columns
WHERE table_name = 'webauthn_credentials' AND column_name IN ('public_key', 'credential_id');
-- Confirm binary storage integrity
SELECT credential_id, octet_length(public_key) AS cose_byte_length
FROM webauthn_credentials
LIMIT 5;
Aligning this schema with your Handling Public Key Storage and Rotation strategy ensures that key lifecycle events (registration, rotation, revocation) operate on immutable cryptographic material.
2. Resolving invalid byte sequence for encoding UTF8 on Insert
This error occurs when application drivers or ORMs implicitly cast binary COSE buffers to UTF-8 strings before executing INSERT statements. PostgreSQL rejects non-UTF-8 byte sequences in TEXT/VARCHAR columns.
Root Causes
- Implicit UTF-8 validation on binary CBOR payloads during
INSERToperations - ORM auto-casting binary buffers to string representations (e.g., Node.js
Buffer.toString('utf8')or Pythonbytes.decode()) - Missing explicit type casting in parameterized queries
Step-by-Step Remediation
- Migrate existing columns to
BYTEA(if previously misconfigured):
ALTER TABLE webauthn_credentials
ALTER COLUMN public_key TYPE BYTEA USING public_key::bytea;
-
Enforce binary parameter binding in application code. Replace string interpolation with explicit binary type hints.
-
Validate CBOR decoding before persistence. Decode only for metadata extraction (e.g.,
alg,key_ops), but persist the original buffer. -
Verify driver encoding settings. Ensure
client_encodingmatches your application’s binary transport expectations.
SHOW client_encoding; -- Should typically be UTF8, but binary columns bypass this validation
Production Patch (Node.js / pg)
import { Pool } from 'pg';
import * as cbor from 'cbor';
const pool = new Pool();
async function storeCredential(credentialId: Buffer, coseKeyBuffer: Buffer, alg: number) {
const query = `
INSERT INTO webauthn_credentials (credential_id, public_key, alg)
VALUES ($1, $2::bytea, $3)
ON CONFLICT (credential_id) DO NOTHING
RETURNING id;
`;
// Ensure raw binary is passed without string conversion
const result = await pool.query(query, [credentialId, coseKeyBuffer, alg]);
return result.rows[0]?.id;
}
3. Indexing Strategy & Algorithm Mapping for Verification
Signature verification requires fast credential lookup and strict algorithm matching. Storing the COSE algorithm ID (alg) as a separate INTEGER column prevents runtime decoding overhead and eliminates mismatch errors during cryptographic operations.
Root Causes
- Missing composite index on
credential_idandalgfields - Storing converted PEM/DER keys instead of original COSE bytes
- Algorithm ID drift between registration and verification payloads (e.g.,
-7for ES256,-8for EdDSA)
Step-by-Step Remediation
- Create strict indexes for lookup and verification routing:
CREATE UNIQUE INDEX idx_credential_id ON webauthn_credentials(credential_id);
CREATE INDEX idx_key_algo ON webauthn_credentials(alg);
- Query with explicit algorithm filtering to prevent cross-algorithm signature validation attempts:
SELECT public_key, alg
FROM webauthn_credentials
WHERE credential_id = $1 AND alg = $2;
- Validate algorithm mapping at the application layer before invoking cryptographic primitives:
const SUPPORTED_ALGORITHMS = new Set([-7, -8, -35, -36, -37, -38, -39]); // ES256, EdDSA, etc.
if (!SUPPORTED_ALGORITHMS.has(alg)) {
throw new Error(`Unsupported COSE algorithm ID: ${alg}`);
}
- Monitor index usage and query plans to prevent sequential scans on high-throughput authentication endpoints:
EXPLAIN ANALYZE
SELECT public_key, alg FROM webauthn_credentials
WHERE credential_id = '\x1a2b3c...'::bytea AND alg = -7;
Proper indexing and algorithm isolation directly support the Backend Verification & Secure Credential Storage architecture by ensuring cryptographic operations execute deterministically under load.
4. Secure Backup, Compliance & Audit Trail Configuration
Enterprise identity platforms must maintain immutable logs for credential lifecycle events. FIDO2 compliance frameworks and SOC 2 Type II audits require traceable records of credential creation, rotation, and revocation. PostgreSQL’s TIMESTAMPTZ and INET types, combined with append-only audit tables, satisfy these requirements without impacting primary authentication performance.
Audit Schema Implementation
CREATE TABLE credential_audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
credential_id BYTEA NOT NULL,
user_id UUID NOT NULL,
action VARCHAR(20) NOT NULL CHECK (action IN ('REGISTERED', 'ROTATED', 'REVOKED', 'VERIFIED')),
actor_ip INET,
user_agent TEXT,
timestamp TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_audit_credential ON credential_audit_log(credential_id);
CREATE INDEX idx_audit_timestamp ON credential_audit_log(timestamp);
Secure Backup & Compliance Controls
- Binary Column Preservation: Use
pg_dumpwith default settings.BYTEAcolumns are exported in hex-escaped format and restored identically. Verify integrity post-restore:
pg_dump -Fc -t webauthn_credentials -t credential_audit_log mydb > auth_backup.dump
pg_restore --list auth_backup.dump | grep -E "webauthn|audit"
- Encryption at Rest: Enable
pgcryptoor Transparent Data Encryption (TDE) via cloud provider configurations. Public keys are non-secret, but credential IDs and audit metadata require protection against enumeration. - Retention Policy: Implement partitioning or scheduled archival for
credential_audit_logto comply with data minimization principles:
-- Example: Archive logs older than 365 days
CREATE TABLE credential_audit_log_archive (LIKE credential_audit_log INCLUDING ALL);
INSERT INTO credential_audit_log_archive
SELECT * FROM credential_audit_log WHERE timestamp < NOW() - INTERVAL '365 days';
DELETE FROM credential_audit_log WHERE timestamp < NOW() - INTERVAL '365 days';
Maintaining strict audit trails and binary integrity ensures your credential storage layer remains compliant and operationally resilient. For advanced lifecycle management, reference Handling Public Key Storage and Rotation to implement automated revocation workflows and cryptographic key retirement policies.