Skip to content

feat(audit): record a bearer refused for the wrong token type - #289

Merged
Bccorb merged 2 commits into
mainfrom
feat/audit-bearer-token-misuse
Sep 8, 2026
Merged

feat(audit): record a bearer refused for the wrong token type#289
Bccorb merged 2 commits into
mainfrom
feat/audit-bearer-token-misuse

Conversation

@Bccorb

@Bccorb Bccorb commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Closes #286.

What

verifyBearerAuth refuses a request before any handler runs and wrote nothing durable, so a caller presenting the wrong kind of token at a protected route left only an application log line. Moving passkey enrollment behind an access session (#287) made that specific: an ephemeral token offered at /webauthn/register/start is exactly the account takeover probe the gate exists to stop, and refusing it was invisible.

A refused bearer now writes a bearer_token_failed auth event when the presented token verifies against this issuer's keys but its typ is not the one the route requires. The row carries the expected and presented types, the matched route pattern, and the token's subject.

Why it is narrower than "any 401"

The issue calls this out: fired on every expired access token, the event buries the signal it exists to surface. The trigger is therefore "a credential this server issued, offered at a gate it does not open", not "the request was refused".

A missing header, a malformed string, an unknown kid, a bad signature and an expired token all cost a caller nothing to produce, and one scanner, or one signing key rotation retiring every outstanding token at once, would fill the window with rows that name nobody. A token of the wrong type has to have been minted here first, which bounds the volume to real flows. Widening this waits on retention and bulk export (#173).

findMisusedBearer reads the unverified typ claim first, purely to decide whether a second signature verification is worth spending. That keeps the ordinary refusals (expired token, rotated session) off the extra work, and it never decides anything: the event is built from the verified payload.

Judgement calls, made deliberately

  • userId is null. A refused token has established no principal. The subject goes in metadata, where it says whose flow token is being offered without asserting the caller is that user, and where it cannot be a foreign key: an ephemeral subject may be the decoy /login mints for an address with no usable account, which resolves to no row. docs/security-posture.md now also says what the subject does not prove, since /login mints an ephemeral token from an address alone.
  • No enumeration oracle. The event is written server side and never reflected. The refusal answers the same 401 { "error": "unauthorized" } in the same place whether or not a row is written.
  • No effect on lockout. LOCKOUT_FAILURE_TYPES is hand-named and unchanged, and recordAuthFailure no-ops for a null user anyway. The new type does join FAILURE_EVENT_TYPES, so it reaches the admin anomaly view, which is the point: that detector already searched for bearer_token_failed and nothing emitted it.

Contract note

Minor and additive. bearer_token_failed is a new value of the auth event type, which appears in the GET /admin/auth-events type query enum in openapi.json and src/generated/api.ts (regenerated), and as a new value in event list responses. Nothing existing changes shape, and no route, status code or token claim moves. Worth a look from seamless-auth-admin-dashboard if it renders event types from a closed list, and from @seamless-auth/types if it keeps its own copy of the enum.

Checks

  • npm run typecheck, npm run lint, npm run format:check, npm run build: clean
  • npm run test:run: 114 files, 1353 passed, 1 skipped, 1 todo
  • npm run coverage: 98.75% statements, 96.06% branches, 98.96% functions, 98.95% lines, all above threshold. The new module is at 100%.
  • /security-review on the branch: no findings. The one sub-threshold observation it raised (a third party can produce a row naming a victim's subject) is now documented in docs/security-posture.md.

verifyBearerAuth refuses a request before any handler runs and wrote nothing
durable, so a caller presenting the wrong kind of token at a protected route left
only an application log line. Moving passkey enrollment behind an access session
made that specific: an ephemeral token offered at /webauthn/register/start is the
account takeover probe the gate exists to stop.

The refusal now writes bearer_token_failed when the presented token verifies
against this issuer's keys but its typ is not the one the route requires,
carrying the expected and presented types, the matched route pattern and the
token's subject.

Narrower than any 401 on purpose. A missing, malformed, expired or unsigned
credential costs a caller nothing to produce, and recording those would let one
scanner, or one signing key rotation, bury the rows that name a real attempt.
Widening waits on audit retention (#173).

Closes #286
@Bccorb
Bccorb merged commit 27ae33e into main Sep 8, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant