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
- Configure a private portal with outbound email enabled.
- Send a portal invitation to an email address that does not already have an account.
- 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.
- Observe that the page automatically requests:
/api/auth/magic-link/verify?token=...
- The user is created and the portal invitation is marked accepted automatically.
- Open the original invitation URL again in a clean browser context.
- 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
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_TOKENThe page displays:
Steps to reproduce
/verify-magic-linkURL in one browser context and allow the page JavaScript to render, but do not intentionally click an acceptance button./api/auth/magic-link/verify?token=...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:
In the observed incident:
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:
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]
allowedAttemptsis 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:
/complete-signupflow.allowedAttemptsoption and relies on the/verify-magic-linkJavaScript interstitial to protect tokens from non-JavaScript scanners.This report concerns a remaining portal-specific gap. The current
parseInvitationId()implementation recognizes only/complete-signup/.... A/portal-invite/...callback therefore falls through toGenericVerifyPage, which automatically calls the token-consuming verification endpoint.No currently open pull request modifies this matcher or
verify-magic-link.tsx.Environment