Invitation Links Aren't Identity
An invitation link should grant one bounded transition, not decide identity. CoreMVP separates possession, authentication, recipient matching, and membership creation.
An invitation system is not finished because it can generate a link and create a membership.
It is finished when a forwarded, repeated, expired, revoked, or wrongly opened link still cannot become someone else's authority.
That is the standard we apply to Organizations. The link proves possession of one invitation capability. The signed-in account proves who is accepting it. Membership is created only when those facts agree and the invitation is still valid.
This matters because copied links move. They end up in chat history, browser history, screenshots, support tickets, and the wrong inbox. If possession of the URL is enough to join an organization, the link has silently become both the invitation and the identity system.
One link should prove one thing
When an owner creates an organization invitation, the server generates a random token and returns a copied link.
The token is useful because it is hard to guess. But secrecy does not tell us who holds it.
A forwarded link is still the same token. A link opened in the wrong browser is still the same token. A token recovered from a screenshot is still the same token.
We therefore treat the token as a bounded capability: possession proves only that the caller has the invitation secret.
Identity comes from the authenticated account.
In the shipped Organizations flow, the accepting user must have:
- an authenticated Supabase session;
- a confirmed, non-anonymous account;
- the exact confirmed email address named by the invitation.
Only then can the invitation capability become membership.
No browser-controlled value gets to own the whole decision.
Store a verifier, not the bearer token
The raw invitation token exists only long enough to give the inviter a link to copy.
We store a SHA-256 hash of that token with the invitation record. The invitation also carries the target email, role, organization, creator, and expiry. Current invitations expire after seven days.
When the recipient accepts the link, the server hashes the submitted token and looks up the matching invitation.
That gives the database enough information to verify possession without retaining the reusable bearer value itself.
It does not make a leaked token harmless. Anyone who has the raw token can still present the capability until it expires, is revoked, or is consumed. Hashing limits what a database disclosure reveals; the identity check still decides whether the holder may turn that capability into membership.
Remove the token from browser history early
The copied CoreMVP invitation uses the URL fragment:
/dashboard/organizations/invitations/accept#token=...The acceptance component reads the fragment, removes it from the visible browser URL with history.replaceState, then sends the token to the same-origin acceptance API.
A URL fragment is not sent as part of the initial HTTP request to the page. Removing it quickly also reduces how long the bearer value remains in normal browser history and copy/paste surfaces.
That is useful hygiene, not authorization.
The server still authenticates the current Supabase user independently. The fragment never gets to decide who is accepting the invitation.
Consumption must be atomic
A one-time invitation is easy to describe and easy to weaken if its state transition is split across several writes.
A weak flow can look like this:
Concurrent requests can race between the read and the final update.
We consume the invitation inside one database transaction instead.
The repository deletes the matching unexpired token hash, verifies that the invitation email matches the authenticated user's confirmed email, verifies that the user is not already the owner or a member, and inserts the membership.
If any check fails, the transaction fails with it. The invitation is not half-consumed and membership is not half-created.
The useful state transition is atomic:
A second use cannot find the consumed invitation.
Revocation works from the other direction: remove the invitation record and the capability stops resolving.
Why email alone is not enough
The inverse shortcut is weak too.
Suppose the server accepts any signed-in user whose email matches a pending invitation. Now the invitation URL is mostly ceremony. A user who knows or guesses that an invitation exists may be able to claim it without possessing the intended capability.
The link and the identity answer different questions:
| Check | Question |
|---|---|
| Invitation token | Does this caller possess the specific invitation capability? |
| Authenticated user | Who is making the request now? |
| Confirmed invited email | Is this authenticated identity the intended recipient? |
| Expiry / revocation / one-time state | Is this capability still usable? |
| Membership policy | What authority does the new member receive after acceptance? |
Collapsing these checks makes one signal carry authority it was never meant to own.
Keep authentication and invitation state separate
We deliberately do not carry the invitation bearer token through sign-in. The recipient signs in with the invited email first, then opens the copied link.
That removes a second temporary token lifecycle from the supported path. There is no generic auth redirect state that also has to preserve, recover, expire, and clear an organization bearer capability.
A product can add a one-click flow that survives authentication, but then that path must answer the same questions explicitly:
- Where is the invitation token stored during authentication?
- Which redirect target may receive it?
- How long does that temporary state live?
- Can another signed-in account resume the pending invitation?
- What clears the token after failure or cancellation?
The better default is not the path with the most convenience hidden inside it. It is the path whose authority remains explicit enough to reason about and prove.
What Organizations support means in CoreMVP
We do not call Organizations supported just because an invitation screen exists.
The shipped path separates:
- possession of the invitation capability;
- authenticated identity;
- confirmed recipient matching;
- expiry and revocation;
- one-time atomic consumption;
- the bounded role granted after acceptance.
Those are the small details that determine whether an invitation stays an invitation instead of becoming an unauthorized membership path.
The reusable rule is simple: treat links as capabilities with explicit scope and lifetime. Treat identity as a separate fact established by the authentication system. Join them only for the one transition both facts authorize.
Read Organizations for the shipped invitation boundary and Test Organizations for the negative proof and its limits.
I'm Antoine. I build CoreMVP and write about the problems I run into, the decisions I make, and what I learn along the way.