Credential Indexing and Database Schema Design

Aligning your relational schema with the WebAuthn specification is the foundation of reliable, auditable passkey infrastructure. The decisions made here — binary column types, index selectivity, COSE field constraints, lifecycle columns — cascade directly into authentication latency, signCount replay detection, and regulatory compliance. This page is part of Backend Verification and Secure Credential Storage, which covers the full server-side verification pipeline.

Concept Definition and Spec Grounding

WebAuthn defines a public key credential as the tuple (credentialId, credentialPublicKey, signCount) returned during the registration ceremony (W3C WebAuthn §6.1). Each field has strict type semantics that must be honoured in your persistence layer:

Field Spec reference PostgreSQL type Notes
credentialId §6.1, rawId BYTEA Raw binary; never coerce to TEXT
credentialPublicKey §6.1, COSE_Key map BYTEA COSE-encoded; store the full CBOR map
signCount §6.1.1 INTEGER NOT NULL DEFAULT 0 Must be monotonically increasing
aaguid §6.4.1 UUID or BYTEA(16) 16-byte authenticator attestation GUID
transports §5.8.4 TEXT[] Values: usb, nfc, ble, internal, hybrid

The credentialId is opaque binary output from the authenticator — it must not be decoded, normalised, or truncated before storage. Base64url encoding is only used at the transport layer (JSON payload); by the time data reaches your persistence layer, it must be raw Buffer/BYTEA.

The aaguid (Authenticator Attestation GUID) is a 16-byte identifier that maps to an authenticator model in the FIDO Alliance Metadata Service (MDS3). Store it even if you do not currently query it — retroactive MDS3 lookups and enterprise authenticator policy enforcement depend on it.

Base64url-to-binary conversion

/**
 * Converts a base64url-encoded WebAuthn payload to a raw binary Buffer.
 * Call this before any database write — never persist the base64url string.
 * Node.js Buffer.from() handles the URL-safe alphabet natively.
 */
export function base64urlToBuffer(value: string): Buffer {
  return Buffer.from(value, 'base64url');
}

export function bufferToBase64url(buf: Buffer): string {
  return buf.toString('base64url');
}

Architecture and Data Flow

The diagram below shows how a registration payload flows from the WebAuthn client response into the credential schema, and how the same schema fields are read back during the authentication ceremony.

Credential Schema Data Flow Diagram showing how credentialId, publicKey, signCount, and aaguid flow from the WebAuthn registration response into the credentials table, and how those same fields are read during authentication verification. REGISTRATION WebAuthn Client navigator.credentials.create() JSON payload base64url fields Registration Endpoint Verify attestation · decode CBOR base64urlToBuffer() credentials table credential_id BYTEA UNIQUE public_key BYTEA sign_count INTEGER DEFAULT 0 aaguid UUID AUTHENTICATION WebAuthn Client navigator.credentials.get() rawId + signature Authentication Endpoint Lookup by credential_id Verify signature + signCount SELECT public_key, sign_count WHERE... UPDATE sign_count = :new WHERE new > stored Indexes used idx_credentials_active (partial) idx_credentials_user_id (B-tree)

Implementation Guide

Step 1 — Create the core credentials table (§6.1 compliance)

The DDL below maps each WebAuthn spec field to its correct PostgreSQL type. The alg column is constrained to known COSE algorithm identifiers so that algorithm downgrade attacks are rejected at the persistence layer rather than in application code.

CREATE TABLE 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,                    -- full COSE_Key CBOR map
  alg           INTEGER     NOT NULL
                            CHECK (alg IN (-7, -8, -257, -35, -36, -37, -38, -39)),
  aaguid        UUID,                                    -- nullable: not all flows provide it
  transports    TEXT[]      NOT NULL DEFAULT '{}',
  sign_count    INTEGER     NOT NULL DEFAULT 0,
  backup_eligible BOOLEAN   NOT NULL DEFAULT FALSE,      -- WebAuthn §6.1 BE flag
  backup_state    BOOLEAN   NOT NULL DEFAULT FALSE,      -- WebAuthn §6.1 BS flag
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  revoked_at    TIMESTAMPTZ,                             -- soft-delete; NULL = active
  revoked_reason TEXT,
  CONSTRAINT uq_credential_id UNIQUE (credential_id)
);

Spec annotations:

  • backup_eligible / backup_state map to the BE and BS flags in the authenticatorData byte layout (§6.1, bits 3 and 4 of the flags byte). These are required for passkey sync detection.
  • alg IN (-7, -8, -257, ...) — COSE algorithm registry (IANA COSE Algorithms). -7 = ES256, -8 = EdDSA, -257 = RS256.
  • The aaguid column is nullable because none-attestation and indirect-attestation flows do not yield a verified AAGUID.

Step 2 — Apply B-tree, partial, and GIN indexes

Index selection for the credentials table is unusual because the read pattern is asymmetric: authentication lookups are an exact equality match on credential_id, while management queries scan by user_id and filter by revocation status.

-- Primary authentication path: exact binary equality on credential_id
-- The partial filter eliminates revoked rows from the index entirely
CREATE UNIQUE INDEX idx_credentials_active
  ON credentials (credential_id)
  WHERE revoked_at IS NULL;

-- User management queries: list all credentials for a user
CREATE INDEX idx_credentials_user_id
  ON credentials (user_id);

-- Covering index for the full authentication hot path — avoids heap fetch
-- for the public_key and sign_count columns (PostgreSQL 11+)
CREATE INDEX idx_credentials_lookup
  ON credentials (credential_id)
  INCLUDE (public_key, sign_count, alg)
  WHERE revoked_at IS NULL;

-- AAGUID lookups for MDS3 policy enforcement or revocation by model
CREATE INDEX idx_credentials_aaguid
  ON credentials (aaguid)
  WHERE aaguid IS NOT NULL;

-- GIN index for transport-aware credential selection
CREATE INDEX idx_credentials_transports
  ON credentials USING GIN (transports);

-- Partial index for recently created credentials (migration validation window)
CREATE INDEX idx_credentials_recent
  ON credentials (created_at DESC)
  WHERE created_at > NOW() - INTERVAL '30 days';

Step 3 — Enforce signCount monotonicity

The WebAuthn spec (§6.3.3, step 17) requires the RP to compare the incoming signCount from the authenticator against the stored value and reject authentication if the stored value is greater than or equal to the incoming value (for authenticators that support counters). A database trigger provides a defence-in-depth guarantee independent of application code:

CREATE OR REPLACE FUNCTION enforce_sign_count_monotonic()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
  -- Allow update only if the new sign_count is strictly greater than current,
  -- OR if both are 0 (authenticator does not implement counter, per §6.3.3).
  IF NEW.sign_count != 0 AND OLD.sign_count != 0
     AND NEW.sign_count <= OLD.sign_count THEN
    RAISE EXCEPTION
      'sign_count regression: stored=%, incoming=%. Possible cloned authenticator.',
      OLD.sign_count, NEW.sign_count;
  END IF;
  RETURN NEW;
END;
$$;

CREATE TRIGGER trg_sign_count_monotonic
  BEFORE UPDATE OF sign_count ON credentials
  FOR EACH ROW EXECUTE FUNCTION enforce_sign_count_monotonic();

Step 4 — Row-level security for multi-tenant isolation

When your RP serves multiple organisations, credential_id uniqueness across the full table is insufficient. Enable PostgreSQL RLS to ensure that queries from one tenant’s application user cannot read or modify another tenant’s credentials:

ALTER TABLE credentials ENABLE ROW LEVEL SECURITY;

-- Policy: users can only see their own credentials
CREATE POLICY credentials_user_isolation
  ON credentials
  USING (user_id = current_setting('app.current_user_id')::UUID);

-- Policy: service accounts with elevated role bypass RLS for admin operations
CREATE POLICY credentials_service_bypass
  ON credentials
  TO service_role
  USING (TRUE);

Set app.current_user_id in your connection wrapper before executing any credential query:

await db.query(`SET LOCAL app.current_user_id = $1`, [userId]);
const credential = await db.query(
  `SELECT credential_id, public_key, sign_count, alg
   FROM credentials
   WHERE credential_id = $1 AND revoked_at IS NULL`,
  [credentialIdBuffer]
);

Step 5 — Prisma schema with correct type mapping

model Credential {
  id             String    @id @default(uuid()) @db.Uuid
  userId         String    @db.Uuid
  credentialId   Bytes                              // BYTEA — raw binary, not base64
  publicKey      Bytes                              // BYTEA — full COSE_Key CBOR map
  alg            Int                                // COSE algorithm ID: -7, -8, -257
  aaguid         String?   @db.Uuid                // nullable: none/indirect attestation
  transports     String[]  @default([])
  signCount      Int       @default(0)
  backupEligible Boolean   @default(false)         // authenticatorData BE flag
  backupState    Boolean   @default(false)         // authenticatorData BS flag
  revokedAt      DateTime?
  revokedReason  String?
  createdAt      DateTime  @default(now())
  updatedAt      DateTime  @updatedAt

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([credentialId])
  @@index([userId])
  @@index([aaguid])
}

Validation Checklist

Error Reference Table

Error / Symptom HTTP status Trigger condition Diagnostic command
duplicate key value violates unique constraint "uq_credential_id" 409 Conflict Double registration attempt; idempotency key missing SELECT COUNT(*) FROM credentials WHERE credential_id = $1
invalid input syntax for type bytea 500 credential_id passed as base64url string, not Buffer Check typeof credentialId before db.query()
new row for relation "credentials" violates check constraint "credentials_alg_check" 422 Authenticator returned an algorithm ID not in your allowlist Log alg from COSE map key 3; update CHECK if algorithm is legitimate
sign_count regression (trigger exception) 401 Incoming signCount ≤ stored; possible cloned authenticator Log both values; flag credential for review; do not silently update
ERROR: null value in column "credential_id" violates not-null constraint 500 rawId not decoded before INSERT Call base64urlToBuffer(rawId) before persistence
Index-only scan not triggered (auth path slow) INCLUDE index not created or stale statistics EXPLAIN (ANALYZE, BUFFERS) on auth query; run ANALYZE credentials
RLS policy violation — no rows returned 401 app.current_user_id not set in session Confirm SET LOCAL app.current_user_id executes before SELECT

Platform and Library Notes

@simplewebauthn/server returns credentialID as a Uint8Array. Convert with Buffer.from(credentialID) before passing to pg — the pg driver accepts Buffer for BYTEA columns but not Uint8Array directly in all versions.

fido2-lib returns id as a base64url string in the assertion result. Always call base64urlToBuffer() before persistence.

py_webauthn (Python) returns credential_id as bytes. Use psycopg2.Binary(credential_id) or the bytes type with asyncpg — both map to PostgreSQL BYTEA correctly.

WebAuthn4J (Java/Kotlin) maps credentialId to byte[]. Use PreparedStatement.setBytes() or Spring Data’s @Lob with byte[] — avoid mapping to String with any encoding.

iOS passkeys (iCloud Keychain) set backup_eligible = true and backup_state = true and report transports = ['internal', 'hybrid']. Android passkeys (Google Password Manager) behave similarly. Your index on transports using GIN allows filtering by transport type for policy decisions.

Windows Hello does not typically set rk (resident key) flag for platform authenticators registered without explicit discoverable credential policy. The backup_eligible and backup_state flags are both false for Windows Hello FIDO2 platform credentials.

Index Selectivity Validation

Run this query after deploying to staging to confirm your indexes are actually being used:

SELECT
  indexname,
  idx_scan,
  idx_tup_read,
  idx_tup_fetch,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
  CASE
    WHEN idx_scan = 0    THEN 'UNUSED'
    WHEN idx_scan < 100  THEN 'LOW_USAGE'
    ELSE                      'ACTIVE'
  END AS health_status
FROM pg_stat_user_indexes
WHERE tablename = 'credentials'
ORDER BY idx_scan DESC;

An UNUSED result on idx_credentials_active after load testing indicates the query planner is not selecting the partial index — check that your authentication query uses WHERE revoked_at IS NULL explicitly and that table statistics are current (ANALYZE credentials).

Pitfalls and Security Hardening

1. Storing credential_id as base64url TEXT Root cause: copying the id field from the JSON payload directly into a VARCHAR column. During authentication, the incoming rawId (binary) is re-encoded to base64url for the JSON transport, but the stored TEXT value may use standard base64 (with + and /) or have different padding. Equality comparisons silently fail. Mitigation: decode to Buffer/BYTEA at the registration endpoint before any persistence call.

2. Missing backup_eligible and backup_state columns Root cause: implementing against an older WebAuthn Level 2 spec without the BE/BS flags (added in Level 3). Passkeys that sync via iCloud or Google Password Manager will have these flags set; ignoring them means you cannot distinguish a roaming hardware key from a synced passkey, breaking enterprise authenticator policy. Mitigation: add both columns with DEFAULT FALSE; populate from authenticatorData flags byte bit 3 and bit 4 during registration.

3. Hard-deleting credentials on user account deletion Root cause: ON DELETE CASCADE without revoked_at means the credential record is gone before audit logging captures the deletion event. Mitigation: use a deferred deletion pattern — set revoked_at = NOW() on account deletion, run a scheduled archival job after the GDPR Article 17 retention minimum, then hard-delete. See Credential Revocation and Account Recovery for the full revocation workflow.

4. No covering index — heap fetch on every authentication Root cause: a bare CREATE INDEX ON credentials (credential_id) forces PostgreSQL to fetch public_key and sign_count from the heap after the index lookup, adding a second I/O round-trip per authentication. Mitigation: use CREATE INDEX ... INCLUDE (public_key, sign_count, alg) WHERE revoked_at IS NULL to make the authentication query index-only.

5. Omitting AAGUID from the schema Root cause: treating AAGUID as an attestation curiosity rather than an operational field. FIDO Alliance MDS3 publishes security advisories keyed on AAGUID — if you do not store it, you cannot perform retroactive revocation when a specific authenticator model is compromised. Mitigation: store aaguid as a nullable UUID column and index it. Cross-validate against MDS3 during attestation statement validation.

6. signCount stored as BIGINT without monotonicity enforcement Root cause: assuming the application layer will always perform the comparison. When multiple services write to the same database, or when a migration script updates rows, the trigger guard is bypassed. Mitigation: the database trigger in Step 3 above provides the defence-in-depth guarantee. Complement it with application-layer validation in your authentication verification logic.


Related