Skip to content

A request refused at the auth gate reaches no audit event, so credential-enrollment probing is invisible #286

Description

@Bccorb

Summary

verifyBearerAuth refuses a request before any handler runs and records nothing in the
audit trail. It writes an application log line and returns
(src/middleware/verifyBearerAuth.ts:23, :31, :46), and there is no
AuthEventService call in the file. So an operator can see failed OTP codes, failed
assertions and locked accounts in auth_events, but not a caller repeatedly presenting
the wrong kind of token at a protected route.

This is long-standing and applies to every gated route. It is worth a ticket now because
moving passkey enrollment behind the access session made one specific case invisible that
previously was not.

What changed to surface it

Before, /webauthn/register/* accepted an ephemeral token and declared decoy responders,
which logged webauthn:register_start and webauthn:register_finish on every decoy hit.
Enrollment now takes auth: 'access', so an ephemeral token is refused in middleware and
the decoys are gone. The refusal is the right answer. The gap is that the attempt it
refuses, which is exactly the account-takeover probe the gate was added to stop, now
leaves no durable trace.

Raised as a sub-threshold observation in the security review of that change: consistent
with how every other access-gated route behaves, so not a regression, but detection of
enrollment-gate probing is weaker than it was.

Suggested shape

Record the refusal centrally in verifyBearerAuth rather than adding it per route, since
the gap is general:

  • an event type for a rejected bearer, carrying the expected token type, the route, and
    the reason (missing, malformed, wrong typ, unknown kid, expired),
  • userId: null, since a refused token has not established a subject. A token that
    verified but was the wrong type does carry a sub, and whether to record it is a
    judgement call worth making deliberately rather than by accident.

Two things to get right:

  • Volume. This fires on every expired access token, which is ordinary traffic, not an
    attack. It needs either a narrower trigger than "any 401" or the retention story in Audit events have no retention policy and no bulk export #173
    to land first, or it will bury the signal it is meant to surface.
  • Not an enumeration oracle. The event is written server side and never reaches the
    caller, so it does not change any response, but the decoy rules in
    docs/security-posture.md are worth re-reading before adding writes to a pre-auth path.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P4Features and enhancementsenhancementNew feature or requestsecuritySecurity-sensitive: auth, tokens, OTP, sessions, crypto

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions