Skip to content

Bug: Portal invite callbacks bypass manual confirmation and can be consumed by browser preloading #344

Description

@tautvydas-copla

Description

Portal invitation links use the generic magic-link verification page, which automatically submits the token after approximately 600 ms.

This means browser preloading or a JavaScript-capable email link scanner can consume the single-use token, create the user, and accept the portal invitation before the recipient intentionally accepts it. When the recipient later clicks the email link, they are redirected to:

/portal-invite/<invite-id>?error=INVALID_TOKEN

The page displays:

Something went wrong
An unexpected error occurred. Please try again later or contact the workspace admin.

Steps to reproduce

  1. Configure a private portal with outbound email enabled.
  2. Send a portal invitation to an email address that does not already have an account.
  3. Open the emailed /verify-magic-link URL in one browser context and allow the page JavaScript to render, but do not intentionally click an acceptance button.
  4. Observe that the page automatically requests:

/api/auth/magic-link/verify?token=...

  1. The user is created and the portal invitation is marked accepted automatically.
  2. Open the original invitation URL again in a clean browser context.
  3. Observe the redirect to the portal-invite route with error=INVALID_TOKEN.

The first browser context can also be replaced by an email security scanner or browser preloader capable of executing JavaScript.

Expected behavior

Opening or preloading a portal invitation landing page should not consume its token.

Portal invitations should display an explicit Accept invitation button. The verification endpoint should only be called after the recipient clicks that button.

Actual behavior

Portal invitation callbacks fall through to GenericVerifyPage, which automatically redirects to the verification endpoint after approximately 600 ms.

The token is consumed on the first verification request. A later intentional click produces INVALID_TOKEN, even though the invitation has already been marked accepted in the background.

Server log sequence

Sensitive identifiers have been removed:

portal invite sent
GET /verify-magic-link 200
GET /api/auth/magic-link/verify 302
created principal record
portal invite accepted
GET /portal-invite/<invite-id> 200

# Later recipient attempt:
GET /verify-magic-link 200
GET /api/auth/magic-link/verify 302
GET /portal-invite/<invite-id>?error=INVALID_TOKEN 200

In the observed incident:

  • The invitation was sent at 14:06:49 UTC.
  • It was automatically verified and accepted at 14:07:07 UTC.
  • The recipient’s later attempt at 14:11:17 UTC returned INVALID_TOKEN.

The first verification used the same Chrome/macOS client fingerprint and public IP as the recipient, suggesting browser-side loading or preloading rather than a server configuration problem.

Likely cause

In apps/web/src/routes/verify-magic-link.tsx, parseInvitationId() only recognizes team signup callbacks:

const match = path.match(//complete-signup/(invite_[a-z0-9]+)/)

Portal invitation callbacks use:

/portal-invite/invite_...

They are therefore not classified as invitations and are rendered through GenericVerifyPage, whose useEffect() automatically submits the token.

A possible minimal fix is:

  • const match = path.match(//complete-signup/(invite_[a-z0-9]+)/)
  • const match = path.match(//(?:complete-signup|portal-invite)/(invite_[a-z0-9]+)/)

This routes portal invitations through the existing InvitationVerifyPage, which requires the explicit Accept invitation action.

Ordinary sign-in magic links can continue using the automatic verification flow.

Additional context

Better Auth logs the following warning:

[better-auth/magic-link] allowedAttempts is ignored: tokens are consumed atomically on the first verification call (GHSA-hc7v-rggr-4hvx).

Therefore, the configured allowedAttempts: 3 does not protect invitation tokens from preloading.

Related work

This appears related to, but is not resolved by:

This report concerns a remaining portal-specific gap. The current parseInvitationId() implementation recognizes only /complete-signup/.... A /portal-invite/... callback therefore falls through to GenericVerifyPage, which automatically calls the token-consuming verification endpoint.

No currently open pull request modifies this matcher or verify-magic-link.tsx.

Environment

  • Quackback Community Edition: 0.13.1
  • Upstream revision: 003c850
  • Better Auth: 1.6.16
  • Deployment: self-hosted Docker Compose
  • Server OS: Ubuntu 24.04.4 LTS
  • Kernel: 6.8.0-136-generic
  • Docker Engine: 29.5.2
  • Docker Compose: 5.1.4
  • Reverse proxy: Caddy 2.11.3
  • Client User-Agent: Chrome 150 on macOS (Mac OS X 10_15_7 as reported by the UA)
  • Portal visibility: private

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions