Skip to content

API reference

Auto-generated from the source docstrings. Everything below is part of the public, supported API and is importable from the top-level fastapi_passkeys package.

Facade (Layer A)

fastapi_passkeys.Passkeys

High-level entry point wiring config, storage, engine, and HTTP routes.

Source code in src/fastapi_passkeys/api/router.py
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
class Passkeys:
    """High-level entry point wiring config, storage, engine, and HTTP routes."""

    def __init__(
        self,
        *,
        config: PasskeyConfig,
        credential_repository: CredentialRepository,
        challenge_store: ChallengeStore | None = None,
        get_user: GetUser | None = None,
        on_authenticated: OnAuthenticated | None = None,
        engine: WebAuthnEngine | None = None,
        audit: AuditSink | None = None,
        clock: Clock | None = None,
    ) -> None:
        self.config = config
        self._clock = clock or SystemClock()
        self._audit = audit or NullAuditSink()
        self._engine = engine or PyWebAuthnEngine()
        self._credentials = credential_repository
        self._challenges = challenge_store or self._default_store(config, self._clock)
        self._get_user = get_user
        self._on_authenticated = on_authenticated

        self.registration = RegistrationService(
            config=config,
            credentials=self._credentials,
            challenges=self._challenges,
            engine=self._engine,
            clock=self._clock,
            audit=self._audit,
        )
        self.authentication = AuthenticationService(
            config=config,
            credentials=self._credentials,
            challenges=self._challenges,
            engine=self._engine,
            clock=self._clock,
            audit=self._audit,
        )
        self.router = self._build_router()

    @staticmethod
    def _default_store(config: PasskeyConfig, clock: Clock) -> ChallengeStore:
        if config.signing_secret is not None:
            return StatelessChallengeStore(config.signing_secret.get_secret_value(), clock=clock)
        return InMemoryChallengeStore(clock=clock)

    def install_exception_handlers(self, app: FastAPI) -> None:
        """Register JSON error responses for the library's exceptions."""
        install_exception_handlers(app)

    def _build_router(self) -> APIRouter:
        router = APIRouter(tags=["passkeys"])

        @router.post("/register/begin", response_model=BeginResponse)
        async def register_begin(request: Request) -> BeginResponse:
            user = await resolve_user(self._get_user, request)
            options, handle = await self.registration.begin(user)
            return BeginResponse(publicKey=options, state=handle)

        @router.post("/register/finish", response_model=RegisterFinishResponse)
        async def register_finish(body: RegisterFinishRequest) -> RegisterFinishResponse:
            credential = await self.registration.finish(
                response=body.credential, handle=body.state, device_name=body.device_name
            )
            return RegisterFinishResponse(credentialId=bytes_to_b64url(credential.credential_id))

        @router.post("/authenticate/begin", response_model=BeginResponse)
        async def authenticate_begin(body: AuthBeginRequest | None = None) -> BeginResponse:
            options, handle = await self.authentication.begin(
                user_id=body.user_id if body else None
            )
            return BeginResponse(publicKey=options, state=handle)

        @router.post("/authenticate/finish")
        async def authenticate_finish(body: AuthFinishRequest, request: Request) -> Any:
            result = await self.authentication.finish(response=body.credential, handle=body.state)
            if self._on_authenticated is not None:
                return await maybe_await(self._on_authenticated(request, result))
            return {"status": "ok", "userId": result.user_id}

        @router.get("/credentials", response_model=list[CredentialView])
        async def list_credentials(request: Request) -> list[CredentialView]:
            user = await resolve_user(self._get_user, request)
            credentials = await self._credentials.list_by_user(user.id)
            return [CredentialView.from_domain(c) for c in credentials]

        @router.patch("/credentials/{credential_id}", status_code=204)
        async def rename_credential(
            credential_id: str, body: RenameRequest, request: Request
        ) -> Response:
            user = await resolve_user(self._get_user, request)
            raw = _decode_id(credential_id)
            if raw is not None:
                await self._credentials.rename(raw, user.id, body.device_name)
            return Response(status_code=204)

        @router.delete("/credentials/{credential_id}", status_code=204)
        async def delete_credential(credential_id: str, request: Request) -> Response:
            user = await resolve_user(self._get_user, request)
            raw = _decode_id(credential_id)
            if raw is not None:
                await self._credentials.delete(raw, user.id)
                await self._audit.emit(
                    AuditEvent(
                        type=AuditEventType.CREDENTIAL_REVOKED,
                        timestamp=self._clock.now(),
                        user_id=user.id,
                        credential_id=credential_id,
                    )
                )
            return Response(status_code=204)

        return router

install_exception_handlers(app)

Register JSON error responses for the library's exceptions.

Source code in src/fastapi_passkeys/api/router.py
93
94
95
def install_exception_handlers(self, app: FastAPI) -> None:
    """Register JSON error responses for the library's exceptions."""
    install_exception_handlers(app)

fastapi_passkeys.PasskeyConfig

Bases: BaseSettings

Relying-party policy for passkey ceremonies.

Source code in src/fastapi_passkeys/config.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
class PasskeyConfig(BaseSettings):
    """Relying-party policy for passkey ceremonies."""

    model_config = SettingsConfigDict(env_prefix="PASSKEYS_", extra="ignore")

    rp_id: str = Field(description="Relying Party ID — the registrable domain, e.g. 'example.com'.")
    rp_name: str = Field(description="Human-readable RP name shown by authenticators.")
    expected_origins: list[str] = Field(
        description="Allowed web origins, e.g. ['https://example.com']. At least one required.",
    )

    challenge_ttl: timedelta = Field(
        default=timedelta(seconds=300),
        description="Server-enforced challenge lifetime, independent of the client timeout.",
    )
    timeout_ms: int = Field(
        default=60_000,
        ge=1_000,
        description="Client-side ceremony timeout hint passed to the authenticator.",
    )

    user_verification: UserVerification = UserVerification.PREFERRED
    attestation: AttestationPreference = AttestationPreference.NONE
    authenticator_attachment: AuthenticatorAttachment | None = None
    resident_key: ResidentKeyRequirement = ResidentKeyRequirement.PREFERRED
    sign_count_policy: SignCountPolicy = SignCountPolicy.STRICT_REJECT

    signing_secret: SecretStr | None = Field(
        default=None,
        description="HMAC secret for the stateless challenge store. Required only when used.",
    )

    @field_validator("expected_origins")
    @classmethod
    def _validate_origins(cls, value: list[str]) -> list[str]:
        if not value:
            raise ValueError("expected_origins must contain at least one origin")
        for origin in value:
            parsed = urlparse(origin)
            if parsed.scheme not in {"http", "https"} or not parsed.netloc:
                raise ValueError(f"invalid origin: {origin!r} (expected scheme://host[:port])")
            if parsed.path not in ("", "/"):
                raise ValueError(f"origin must not contain a path: {origin!r}")
        return value

    @property
    def require_user_verification(self) -> bool:
        """Whether assertions/attestations must prove user verification."""
        return self.user_verification is UserVerification.REQUIRED

require_user_verification property

Whether assertions/attestations must prove user verification.

Services (Layer B)

fastapi_passkeys.RegistrationService

Begins and finishes passkey registration (attestation) ceremonies.

Source code in src/fastapi_passkeys/services/registration.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
class RegistrationService:
    """Begins and finishes passkey registration (attestation) ceremonies."""

    def __init__(
        self,
        *,
        config: PasskeyConfig,
        credentials: CredentialRepository,
        challenges: ChallengeStore,
        engine: WebAuthnEngine,
        clock: Clock | None = None,
        audit: AuditSink | None = None,
    ) -> None:
        self._config = config
        self._credentials = credentials
        self._challenges = challenges
        self._engine = engine
        self._clock = clock or SystemClock()
        self._audit = audit or NullAuditSink()

    async def begin(self, user: PasskeyUser) -> tuple[dict[str, Any], str]:
        """Issue creation options and a challenge handle the client echoes back."""
        existing = await self._credentials.list_by_user(user.id)
        challenge = secrets.token_bytes(_CHALLENGE_BYTES)
        now = self._clock.now()
        options = self._engine.registration_options(
            config=self._config, user=user, challenge=challenge, exclude=existing
        )
        handle = await self._challenges.put(
            RegistrationChallenge(
                challenge=challenge,
                user=user,
                created_at=now,
                expires_at=now + self._config.challenge_ttl,
            )
        )
        await self._audit.emit(
            AuditEvent(type=AuditEventType.REGISTRATION_BEGAN, timestamp=now, user_id=user.id)
        )
        return options, handle

    async def finish(
        self,
        *,
        response: dict[str, Any] | str,
        handle: str,
        device_name: str = "",
    ) -> Credential:
        """Verify the attestation response and persist the new credential."""
        stored = await self._challenges.consume(handle)
        if not isinstance(stored, RegistrationChallenge):
            await self._fail(AuditEventType.CHALLENGE_EXPIRED, user_id=None)
            raise ChallengeNotFound()

        try:
            verified = self._engine.verify_registration(
                config=self._config, response=response, expected_challenge=stored.challenge
            )
            if await self._credentials.get_by_credential_id(verified.credential_id) is not None:
                raise CredentialAlreadyExists()
        except PasskeyError:
            await self._fail(AuditEventType.REGISTRATION_FAILED, user_id=stored.user.id)
            raise

        now = self._clock.now()
        credential = Credential(
            credential_id=verified.credential_id,
            user_id=stored.user.id,
            public_key=verified.public_key,
            sign_count=verified.sign_count,
            transports=verified.transports,
            aaguid=verified.aaguid,
            backup_eligible=verified.backup_eligible,
            backup_state=verified.backup_state,
            device_name=device_name,
            is_discoverable=self._config.resident_key is ResidentKeyRequirement.REQUIRED,
            attestation_fmt=verified.attestation_fmt,
            created_at=now,
            last_used_at=None,
        )
        await self._credentials.add(credential)
        await self._audit.emit(
            AuditEvent(
                type=AuditEventType.REGISTRATION_SUCCEEDED,
                timestamp=now,
                user_id=stored.user.id,
                credential_id=bytes_to_b64url(verified.credential_id),
            )
        )
        return credential

    async def _fail(self, event_type: AuditEventType, *, user_id: str | None) -> None:
        await self._audit.emit(
            AuditEvent(type=event_type, timestamp=self._clock.now(), user_id=user_id)
        )

begin(user) async

Issue creation options and a challenge handle the client echoes back.

Source code in src/fastapi_passkeys/services/registration.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
async def begin(self, user: PasskeyUser) -> tuple[dict[str, Any], str]:
    """Issue creation options and a challenge handle the client echoes back."""
    existing = await self._credentials.list_by_user(user.id)
    challenge = secrets.token_bytes(_CHALLENGE_BYTES)
    now = self._clock.now()
    options = self._engine.registration_options(
        config=self._config, user=user, challenge=challenge, exclude=existing
    )
    handle = await self._challenges.put(
        RegistrationChallenge(
            challenge=challenge,
            user=user,
            created_at=now,
            expires_at=now + self._config.challenge_ttl,
        )
    )
    await self._audit.emit(
        AuditEvent(type=AuditEventType.REGISTRATION_BEGAN, timestamp=now, user_id=user.id)
    )
    return options, handle

finish(*, response, handle, device_name='') async

Verify the attestation response and persist the new credential.

Source code in src/fastapi_passkeys/services/registration.py
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
async def finish(
    self,
    *,
    response: dict[str, Any] | str,
    handle: str,
    device_name: str = "",
) -> Credential:
    """Verify the attestation response and persist the new credential."""
    stored = await self._challenges.consume(handle)
    if not isinstance(stored, RegistrationChallenge):
        await self._fail(AuditEventType.CHALLENGE_EXPIRED, user_id=None)
        raise ChallengeNotFound()

    try:
        verified = self._engine.verify_registration(
            config=self._config, response=response, expected_challenge=stored.challenge
        )
        if await self._credentials.get_by_credential_id(verified.credential_id) is not None:
            raise CredentialAlreadyExists()
    except PasskeyError:
        await self._fail(AuditEventType.REGISTRATION_FAILED, user_id=stored.user.id)
        raise

    now = self._clock.now()
    credential = Credential(
        credential_id=verified.credential_id,
        user_id=stored.user.id,
        public_key=verified.public_key,
        sign_count=verified.sign_count,
        transports=verified.transports,
        aaguid=verified.aaguid,
        backup_eligible=verified.backup_eligible,
        backup_state=verified.backup_state,
        device_name=device_name,
        is_discoverable=self._config.resident_key is ResidentKeyRequirement.REQUIRED,
        attestation_fmt=verified.attestation_fmt,
        created_at=now,
        last_used_at=None,
    )
    await self._credentials.add(credential)
    await self._audit.emit(
        AuditEvent(
            type=AuditEventType.REGISTRATION_SUCCEEDED,
            timestamp=now,
            user_id=stored.user.id,
            credential_id=bytes_to_b64url(verified.credential_id),
        )
    )
    return credential

fastapi_passkeys.AuthenticationService

Begins and finishes passkey authentication (assertion) ceremonies.

Source code in src/fastapi_passkeys/services/authentication.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
class AuthenticationService:
    """Begins and finishes passkey authentication (assertion) ceremonies."""

    def __init__(
        self,
        *,
        config: PasskeyConfig,
        credentials: CredentialRepository,
        challenges: ChallengeStore,
        engine: WebAuthnEngine,
        clock: Clock | None = None,
        audit: AuditSink | None = None,
    ) -> None:
        self._config = config
        self._credentials = credentials
        self._challenges = challenges
        self._engine = engine
        self._clock = clock or SystemClock()
        self._audit = audit or NullAuditSink()

    async def begin(self, *, user_id: str | None = None) -> tuple[dict[str, Any], str]:
        """Issue request options and a challenge handle.

        Pass ``user_id`` for a username-first flow (restricts allowed credentials)
        or omit it for a usernameless / discoverable-credential flow.
        """
        allow = await self._credentials.list_by_user(user_id) if user_id else []
        challenge = secrets.token_bytes(_CHALLENGE_BYTES)
        now = self._clock.now()
        options = self._engine.authentication_options(
            config=self._config, challenge=challenge, allow=allow
        )
        handle = await self._challenges.put(
            AuthenticationChallenge(
                challenge=challenge,
                user_id=user_id,
                created_at=now,
                expires_at=now + self._config.challenge_ttl,
            )
        )
        await self._audit.emit(
            AuditEvent(type=AuditEventType.AUTHENTICATION_BEGAN, timestamp=now, user_id=user_id)
        )
        return options, handle

    async def finish(self, *, response: dict[str, Any] | str, handle: str) -> AuthenticationResult:
        """Verify the assertion, enforce the signature counter, and record usage."""
        stored = await self._challenges.consume(handle)
        if not isinstance(stored, AuthenticationChallenge):
            await self._fail(AuditEventType.CHALLENGE_EXPIRED, user_id=None)
            raise ChallengeNotFound()

        credential = await self._resolve_credential(response, stored)

        try:
            verified = self._engine.verify_authentication(
                config=self._config,
                response=response,
                expected_challenge=stored.challenge,
                credential=credential,
            )
        except PasskeyError:
            await self._fail(AuditEventType.AUTHENTICATION_FAILED, user_id=credential.user_id)
            raise

        await self._enforce_sign_count(credential, verified)

        now = self._clock.now()
        await self._credentials.update_usage(
            credential.credential_id, sign_count=verified.new_sign_count, last_used_at=now
        )
        await self._audit.emit(
            AuditEvent(
                type=AuditEventType.AUTHENTICATION_SUCCEEDED,
                timestamp=now,
                user_id=credential.user_id,
                credential_id=bytes_to_b64url(credential.credential_id),
            )
        )
        updated = dataclasses.replace(
            credential, sign_count=verified.new_sign_count, last_used_at=now
        )
        return AuthenticationResult(
            user_id=credential.user_id,
            credential=updated,
            detail={"user_verified": verified.user_verified},
        )

    async def _resolve_credential(
        self, response: dict[str, Any] | str, stored: AuthenticationChallenge
    ) -> Credential:
        credential_id = _credential_id_from(response)
        credential = (
            await self._credentials.get_by_credential_id(credential_id)
            if credential_id is not None
            else None
        )
        # When the ceremony was bound to a user, reject credentials owned by anyone
        # else — using the same generic error so we do not leak which check failed.
        if credential is None or (
            stored.user_id is not None and credential.user_id != stored.user_id
        ):
            await self._fail(AuditEventType.AUTHENTICATION_FAILED, user_id=stored.user_id)
            raise CredentialNotFound()
        return credential

    async def _enforce_sign_count(
        self, credential: Credential, verified: VerifiedAuthentication
    ) -> None:
        old, new = credential.sign_count, verified.new_sign_count
        # Counter of 0 on both sides means the authenticator does not implement one.
        if new == 0 and old == 0:
            return
        if new > old:
            return
        await self._audit.emit(
            AuditEvent(
                type=AuditEventType.CLONE_SUSPECTED,
                timestamp=self._clock.now(),
                user_id=credential.user_id,
                credential_id=bytes_to_b64url(credential.credential_id),
                detail={"stored_sign_count": old, "presented_sign_count": new},
            )
        )
        if self._config.sign_count_policy is SignCountPolicy.FLAG_DISABLE:
            await self._credentials.delete(credential.credential_id, credential.user_id)
        raise CloneDetectedError()

    async def _fail(self, event_type: AuditEventType, *, user_id: str | None) -> None:
        await self._audit.emit(
            AuditEvent(type=event_type, timestamp=self._clock.now(), user_id=user_id)
        )

begin(*, user_id=None) async

Issue request options and a challenge handle.

Pass user_id for a username-first flow (restricts allowed credentials) or omit it for a usernameless / discoverable-credential flow.

Source code in src/fastapi_passkeys/services/authentication.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
async def begin(self, *, user_id: str | None = None) -> tuple[dict[str, Any], str]:
    """Issue request options and a challenge handle.

    Pass ``user_id`` for a username-first flow (restricts allowed credentials)
    or omit it for a usernameless / discoverable-credential flow.
    """
    allow = await self._credentials.list_by_user(user_id) if user_id else []
    challenge = secrets.token_bytes(_CHALLENGE_BYTES)
    now = self._clock.now()
    options = self._engine.authentication_options(
        config=self._config, challenge=challenge, allow=allow
    )
    handle = await self._challenges.put(
        AuthenticationChallenge(
            challenge=challenge,
            user_id=user_id,
            created_at=now,
            expires_at=now + self._config.challenge_ttl,
        )
    )
    await self._audit.emit(
        AuditEvent(type=AuditEventType.AUTHENTICATION_BEGAN, timestamp=now, user_id=user_id)
    )
    return options, handle

finish(*, response, handle) async

Verify the assertion, enforce the signature counter, and record usage.

Source code in src/fastapi_passkeys/services/authentication.py
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
async def finish(self, *, response: dict[str, Any] | str, handle: str) -> AuthenticationResult:
    """Verify the assertion, enforce the signature counter, and record usage."""
    stored = await self._challenges.consume(handle)
    if not isinstance(stored, AuthenticationChallenge):
        await self._fail(AuditEventType.CHALLENGE_EXPIRED, user_id=None)
        raise ChallengeNotFound()

    credential = await self._resolve_credential(response, stored)

    try:
        verified = self._engine.verify_authentication(
            config=self._config,
            response=response,
            expected_challenge=stored.challenge,
            credential=credential,
        )
    except PasskeyError:
        await self._fail(AuditEventType.AUTHENTICATION_FAILED, user_id=credential.user_id)
        raise

    await self._enforce_sign_count(credential, verified)

    now = self._clock.now()
    await self._credentials.update_usage(
        credential.credential_id, sign_count=verified.new_sign_count, last_used_at=now
    )
    await self._audit.emit(
        AuditEvent(
            type=AuditEventType.AUTHENTICATION_SUCCEEDED,
            timestamp=now,
            user_id=credential.user_id,
            credential_id=bytes_to_b64url(credential.credential_id),
        )
    )
    updated = dataclasses.replace(
        credential, sign_count=verified.new_sign_count, last_used_at=now
    )
    return AuthenticationResult(
        user_id=credential.user_id,
        credential=updated,
        detail={"user_verified": verified.user_verified},
    )

Domain models

fastapi_passkeys.PasskeyUser dataclass

The subject of a ceremony.

id is your stable internal identifier (it becomes the WebAuthn user handle). name and display_name are shown by the authenticator UI.

Source code in src/fastapi_passkeys/domain/models.py
16
17
18
19
20
21
22
23
24
25
26
@dataclass(frozen=True, slots=True)
class PasskeyUser:
    """The subject of a ceremony.

    ``id`` is your stable internal identifier (it becomes the WebAuthn user
    handle). ``name`` and ``display_name`` are shown by the authenticator UI.
    """

    id: str
    name: str
    display_name: str

fastapi_passkeys.Credential dataclass

A registered passkey, as persisted by a :class:CredentialRepository.

Source code in src/fastapi_passkeys/domain/models.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@dataclass(frozen=True, slots=True)
class Credential:
    """A registered passkey, as persisted by a :class:`CredentialRepository`."""

    credential_id: bytes
    user_id: str
    public_key: bytes
    sign_count: int
    transports: tuple[Transport, ...] = ()
    aaguid: bytes | None = None
    backup_eligible: bool = False
    backup_state: bool = False
    device_name: str = ""
    is_discoverable: bool = False
    attestation_fmt: str | None = None
    created_at: datetime | None = None
    last_used_at: datetime | None = None

fastapi_passkeys.AuthenticationResult dataclass

Returned to the application once a passkey assertion is verified.

Source code in src/fastapi_passkeys/domain/models.py
 97
 98
 99
100
101
102
103
@dataclass(frozen=True, slots=True)
class AuthenticationResult:
    """Returned to the application once a passkey assertion is verified."""

    user_id: str
    credential: Credential
    detail: dict[str, object] = field(default_factory=dict)

Storage protocols

fastapi_passkeys.CredentialRepository

Bases: Protocol

Persistence for registered passkeys. All methods are coroutines.

Source code in src/fastapi_passkeys/repositories/credentials.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@runtime_checkable
class CredentialRepository(Protocol):
    """Persistence for registered passkeys. All methods are coroutines."""

    async def add(self, credential: Credential) -> None:
        """Persist a newly registered credential."""
        ...

    async def get_by_credential_id(self, credential_id: bytes) -> Credential | None:
        """Return the credential with this id, or ``None`` if unknown."""
        ...

    async def list_by_user(self, user_id: str) -> list[Credential]:
        """Return all credentials registered to a user (may be empty)."""
        ...

    async def update_usage(
        self,
        credential_id: bytes,
        *,
        sign_count: int,
        last_used_at: datetime,
    ) -> None:
        """Record a successful authentication: advance counter and last-used."""
        ...

    async def rename(self, credential_id: bytes, user_id: str, name: str) -> None:
        """Set a user-facing device name. Scoped to ``user_id`` for safety."""
        ...

    async def delete(self, credential_id: bytes, user_id: str) -> None:
        """Revoke a credential. Scoped to ``user_id`` so users cannot delete others'."""
        ...

add(credential) async

Persist a newly registered credential.

Source code in src/fastapi_passkeys/repositories/credentials.py
21
22
23
async def add(self, credential: Credential) -> None:
    """Persist a newly registered credential."""
    ...

delete(credential_id, user_id) async

Revoke a credential. Scoped to user_id so users cannot delete others'.

Source code in src/fastapi_passkeys/repositories/credentials.py
47
48
49
async def delete(self, credential_id: bytes, user_id: str) -> None:
    """Revoke a credential. Scoped to ``user_id`` so users cannot delete others'."""
    ...

get_by_credential_id(credential_id) async

Return the credential with this id, or None if unknown.

Source code in src/fastapi_passkeys/repositories/credentials.py
25
26
27
async def get_by_credential_id(self, credential_id: bytes) -> Credential | None:
    """Return the credential with this id, or ``None`` if unknown."""
    ...

list_by_user(user_id) async

Return all credentials registered to a user (may be empty).

Source code in src/fastapi_passkeys/repositories/credentials.py
29
30
31
async def list_by_user(self, user_id: str) -> list[Credential]:
    """Return all credentials registered to a user (may be empty)."""
    ...

rename(credential_id, user_id, name) async

Set a user-facing device name. Scoped to user_id for safety.

Source code in src/fastapi_passkeys/repositories/credentials.py
43
44
45
async def rename(self, credential_id: bytes, user_id: str, name: str) -> None:
    """Set a user-facing device name. Scoped to ``user_id`` for safety."""
    ...

update_usage(credential_id, *, sign_count, last_used_at) async

Record a successful authentication: advance counter and last-used.

Source code in src/fastapi_passkeys/repositories/credentials.py
33
34
35
36
37
38
39
40
41
async def update_usage(
    self,
    credential_id: bytes,
    *,
    sign_count: int,
    last_used_at: datetime,
) -> None:
    """Record a successful authentication: advance counter and last-used."""
    ...

fastapi_passkeys.ChallengeStore

Bases: Protocol

Single-use storage for in-flight ceremony challenges.

Source code in src/fastapi_passkeys/repositories/challenges.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@runtime_checkable
class ChallengeStore(Protocol):
    """Single-use storage for in-flight ceremony challenges."""

    async def put(self, challenge: RegistrationChallenge | AuthenticationChallenge) -> str:
        """Store a challenge and return an opaque handle the client echoes back."""
        ...

    async def consume(self, handle: str) -> RegistrationChallenge | AuthenticationChallenge | None:
        """Atomically fetch and invalidate the challenge.

        Returns ``None`` if the handle is unknown, already consumed, expired, or
        tampered with. Never returns an expired challenge.
        """
        ...

consume(handle) async

Atomically fetch and invalidate the challenge.

Returns None if the handle is unknown, already consumed, expired, or tampered with. Never returns an expired challenge.

Source code in src/fastapi_passkeys/repositories/challenges.py
24
25
26
27
28
29
30
async def consume(self, handle: str) -> RegistrationChallenge | AuthenticationChallenge | None:
    """Atomically fetch and invalidate the challenge.

    Returns ``None`` if the handle is unknown, already consumed, expired, or
    tampered with. Never returns an expired challenge.
    """
    ...

put(challenge) async

Store a challenge and return an opaque handle the client echoes back.

Source code in src/fastapi_passkeys/repositories/challenges.py
20
21
22
async def put(self, challenge: RegistrationChallenge | AuthenticationChallenge) -> str:
    """Store a challenge and return an opaque handle the client echoes back."""
    ...

Audit

fastapi_passkeys.AuditEvent dataclass

Source code in src/fastapi_passkeys/audit.py
28
29
30
31
32
33
34
@dataclass(frozen=True, slots=True)
class AuditEvent:
    type: AuditEventType
    timestamp: datetime
    user_id: str | None = None
    credential_id: str | None = None  # base64url, never raw bytes
    detail: dict[str, Any] = field(default_factory=dict)

fastapi_passkeys.AuditSink

Bases: Protocol

Receives audit events. Implementations must not raise on the hot path.

Source code in src/fastapi_passkeys/audit.py
37
38
39
40
41
@runtime_checkable
class AuditSink(Protocol):
    """Receives audit events. Implementations must not raise on the hot path."""

    async def emit(self, event: AuditEvent) -> None: ...

Exceptions

Typed exception hierarchy.

Every error the library raises carries a machine-readable code and a default status_code. install_exception_handlers (see :mod:fastapi_passkeys.api) turns these into JSON responses without leaking internal detail. The messages are intentionally generic for verification failures to avoid handing attackers an oracle (e.g. we do not say why an assertion failed).

PasskeyError

Bases: Exception

Base class for every error raised by fastapi-passkeys.

Source code in src/fastapi_passkeys/exceptions.py
13
14
15
16
17
18
19
20
21
class PasskeyError(Exception):
    """Base class for every error raised by fastapi-passkeys."""

    code = "passkey_error"
    status_code = 400

    def __init__(self, message: str | None = None) -> None:
        super().__init__(message or self.__doc__ or self.code)
        self.message = message or (self.__doc__ or self.code)

ConfigurationError

Bases: PasskeyError

The library was configured incorrectly.

Source code in src/fastapi_passkeys/exceptions.py
24
25
26
27
28
class ConfigurationError(PasskeyError):
    """The library was configured incorrectly."""

    code = "configuration_error"
    status_code = 500

ChallengeError

Bases: PasskeyError

A challenge could not be validated.

Source code in src/fastapi_passkeys/exceptions.py
34
35
36
37
38
class ChallengeError(PasskeyError):
    """A challenge could not be validated."""

    code = "challenge_error"
    status_code = 400

ChallengeNotFound

Bases: ChallengeError

The challenge is unknown, already used, or has expired.

Source code in src/fastapi_passkeys/exceptions.py
41
42
43
44
45
class ChallengeNotFound(ChallengeError):
    """The challenge is unknown, already used, or has expired."""

    code = "challenge_not_found"
    status_code = 400

ChallengeExpired

Bases: ChallengeError

The challenge is no longer valid.

Source code in src/fastapi_passkeys/exceptions.py
48
49
50
51
52
class ChallengeExpired(ChallengeError):
    """The challenge is no longer valid."""

    code = "challenge_expired"
    status_code = 400

RegistrationError

Bases: PasskeyError

Registration could not be completed.

Source code in src/fastapi_passkeys/exceptions.py
58
59
60
61
62
class RegistrationError(PasskeyError):
    """Registration could not be completed."""

    code = "registration_error"
    status_code = 400

CredentialAlreadyExists

Bases: RegistrationError

This credential is already registered.

Source code in src/fastapi_passkeys/exceptions.py
65
66
67
68
69
class CredentialAlreadyExists(RegistrationError):
    """This credential is already registered."""

    code = "credential_already_exists"
    status_code = 409

AttestationVerificationError

Bases: RegistrationError

The registration response failed verification.

Source code in src/fastapi_passkeys/exceptions.py
72
73
74
75
76
class AttestationVerificationError(RegistrationError):
    """The registration response failed verification."""

    code = "attestation_verification_failed"
    status_code = 400

AuthenticationError

Bases: PasskeyError

Authentication could not be completed.

Source code in src/fastapi_passkeys/exceptions.py
82
83
84
85
86
class AuthenticationError(PasskeyError):
    """Authentication could not be completed."""

    code = "authentication_error"
    status_code = 401

CredentialNotFound

Bases: AuthenticationError

No matching registered credential was found.

Source code in src/fastapi_passkeys/exceptions.py
89
90
91
92
93
class CredentialNotFound(AuthenticationError):
    """No matching registered credential was found."""

    code = "credential_not_found"
    status_code = 401

AssertionVerificationError

Bases: AuthenticationError

The authentication response failed verification.

Source code in src/fastapi_passkeys/exceptions.py
 96
 97
 98
 99
100
class AssertionVerificationError(AuthenticationError):
    """The authentication response failed verification."""

    code = "assertion_verification_failed"
    status_code = 401

CloneDetectedError

Bases: AuthenticationError

The signature counter did not advance; the authenticator may be cloned.

Source code in src/fastapi_passkeys/exceptions.py
103
104
105
106
107
class CloneDetectedError(AuthenticationError):
    """The signature counter did not advance; the authenticator may be cloned."""

    code = "clone_detected"
    status_code = 401