Passkey model & schema
RoundlyConsulting\Passkeys\Models\Passkey is a soft-deleting, polymorphic Eloquent model for a stored WebAuthn credential. Its owner is the authenticatable morph-to relation, and three scopes cover the lookups:
use RoundlyConsulting\Passkeys\Models\Passkey;
$passkey = Passkey::query()->forCredentialId($base64UrlId)->first();
$owned = Passkey::query()->forUserHandle($handle)->get();
$mine = Passkey::query()->ownedBy($user)->get();
$passkey->authenticatable; // the owning model (MorphTo)- forCredentialId($id) — match by credential ID through its sha-256 lookup hash.
- forUserHandle($handle) — match by discoverable-login user handle.
- ownedBy($owner) — exactly one owner: its morph type and its key.
- Passkey::hashCredentialId($id) — the static sha-256 lookup hash of a (possibly long) base64url credential ID.
- advanceSignCount($signCount, $backupState) — one conditional UPDATE that moves sign_count forward, records backup_state and stamps last_used_at; false (with the model reloaded) when the counter did not advance.
- recordUsage($backupState) — record backup_state and stamp last_used_at without moving the counter (a flag-policy regression).
- Casts: transports (array), sign_count (integer), backup_eligible and backup_state (boolean), last_used_at (datetime).
Schema
| Column | Type | Notes |
|---|---|---|
id | bigint PK | |
authenticatable_type / _id | morph key | The owning model; key type follows passkeys.key_type. |
credential_id | text | Base64url credential ID (up to ~1364 chars for roaming keys). |
credential_id_hash | char(64), unique | sha-256 of credential_id — the real lookup key. |
public_key | text | Base64 of the raw COSE public key bytes. |
user_handle | string, indexed | Opaque discoverable-login handle. |
transports | jsonb, nullable | Reported authenticator transports. |
aaguid | uuid, nullable | Authenticator model ID. |
sign_count | unsigned bigint | Default 0 — the clone-detection counter; it only ever moves forward. |
name | string, nullable | Cosmetic label. |
attestation_format | string, nullable | Recorded statement format (none, packed, apple, …). |
attestation_type | string(16), nullable | Grade of proof established (none, self, basic, anonca); null on rows registered before the column existed. |
backup_eligible / backup_state | boolean | Default false. backup_eligible is fixed at registration; backup_state is updated by every accepted assertion. |
last_used_at | timestamp, nullable | Stamped on every successful sign-in. |
created_at / updated_at | timestamps | |
deleted_at | soft deletes | Revoked credentials are soft-deleted. |
The unique index sits on the fixed-length credential_id_hash rather than the variable-length credential_id, so roaming-key IDs are stored in full and the index fits every database’s key-length limit.
Using your own model
Point passkeys.model at a subclass of Passkey. The package resolves the configured class everywhere — the passkeys() relation, both ceremonies and the factory — so registration hands your class back and its model events fire. A class that is not Passkey or a subclass of it throws InvalidConfigurationException naming the key — it is never silently replaced by the packaged model. Keep the scopes, casts, hidden attributes and hashCredentialId() intact; the ceremonies rely on them:
// app/Models/Credential.php
use RoundlyConsulting\Passkeys\Models\Passkey;
class Credential extends Passkey
{
// your accessors, relationships and model events
}
// config/passkeys.php
'model' => App\Models\Credential::class,UUID and ULID owners
The authenticatable morph column defaults to bigint. If the models that own passkeys use UUID or ULID primary keys, set the key type before migrating — every owning model must share one key type, and an unrecognised value throws InvalidConfigurationException rather than silently building bigint keys:
PASSKEYS_KEY_TYPE=uuid # bigint (default), uuid or ulidShow your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.