PasskeyStore

The ten-method persistence contract and the three rules that matter.

passkify never touches your database. You implement PasskeyStore against whatever you already run, and the library calls it.

const passkeys = new PasskeyServer({ rpName, origin, store });

Ten methods. None of them need transactions, and only one has a subtle requirement.

The three rules

Everything else is a plain read or write. These three are where a store goes wrong.

takeChallenge must deleterule

Fetching and deleting has to be one operation, atomic if your database can do it.

DELETE FROM passkey_challenges WHERE challenge = $1 RETURNING *;

Why it works this way

createUser must persist the id it is givenrule

async createUser({ id, username, displayName }) {
  await db.insert({ id, username, displayName });   // store `id` verbatim
  return { id, username, displayName };
}

Why it works this way

Index what gets looked uprule

credentials.idunique
credentials.user_idindex
users.usernameunique, case-insensitive
challenges.challengeprimary key

The records

PasskeyUser

{ id: string, username: string, displayName: string }
idstringrequired
usernamestringrequired
displayNamestringrequired

Why the user handle must not be an email address

PasskeyCredential

{
  id: string,            // base64url credential ID, unique across ALL users
  userId: string,
  publicKey: string,     // base64url COSE key bytes
  algorithm: number,     // COSE identifier, -7 for ES256
  counter: number,
  transports?: string[],
  deviceType: 'singleDevice' | 'multiDevice',
  backedUp: boolean,
  aaguid: string,
  createdAt: Date,
  lastUsedAt?: Date,
  nickname?: string,
}

PasskeyChallenge

{
  challenge: string,     // base64url, also the primary key
  kind: 'registration' | 'authentication',
  userId?: string,
  expiresAt: Date,
  context?: Record<string, unknown>,
}

Why context exists

Methods

getUserByUsernamemethod

getUserByUsername(username: string): Promise<PasskeyUser | null>

Look up an account by the username the visitor typed. Return null when there is none; throwing is for infrastructure failures.

Match case-insensitively.

getUserByIdmethod

getUserById(id: string): Promise<PasskeyUser | null>

Look up an account by its user handle. Called on every usernameless login, so this one is hot.

createUsermethod

createUser(input: { id, username, displayName }): Promise<PasskeyUser>

Create an account. Persist input.id verbatim; see the rule above.

createCredentialmethod

createCredential(credential: PasskeyCredential): Promise<void>

Persist a newly verified credential. Called once, after every check has passed.

getCredentialByIdmethod

getCredentialById(id: string): Promise<PasskeyCredential | null>

Fetch by base64url credential ID, across all users. The single hottest call in the library.

Why it is not scoped to a user

listCredentialsByUserIdmethod

listCredentialsByUserId(userId: string): Promise<PasskeyCredential[]>

Every credential on an account. Used for excludeCredentials, allowCredentials, and the settings page.

updateCredentialmethod

updateCredential(id: string, changes: Partial<Pick<PasskeyCredential, 'counter' | 'lastUsedAt' | 'backedUp' | 'nickname'>>): Promise<void>

Apply a partial update. Only the four listed fields are ever changed.

Why only four fields are mutable

deleteCredentialmethod

deleteCredential(id: string): Promise<void>

Remove a credential. Deleting one that does not exist should be a no-op, not an error.

saveChallengemethod

saveChallenge(challenge: PasskeyChallenge): Promise<void>

Store an outstanding challenge. If your store has native TTL, use it; passkify also checks expiresAt itself, so expiry is enforced either way.

takeChallengemethod

takeChallenge(challenge: string): Promise<PasskeyChallenge | null>

Fetch and delete, atomically. Return null if missing or expired. See the rule above.

Next