Skip to content
Chapter 58Lesson 3

Four arrival shapes on one accept URL

Build the invitation accept flow, the page and Server Action that turn one signed accept URL into an organization membership across every way a user can arrive.

Three days after Alice sent it, Bob finally opens the email and clicks the link. Who is Bob at the instant of that click? He might already be signed in as bob@acme.com, or signed in as some other account, or signed out with an account waiting for him, or signed out with no account at all. That is four different people behind one URL, and the same row in the invitation table has to become a member row for exactly one of them: the right one, authenticated as the invited address, who has actually said yes.

The previous lesson mailed the credential. This lesson builds the lock it opens: an /accept-invite page that routes all four arrivals, an acceptInvitation action that writes the member row only behind a consent click, and a flow that stays safe even when Bob double-clicks the link.

Everything in this lesson rests on one structural fact: a link in an email is a GET. Bob clicks it, his browser issues GET /accept-invite?id=…&token=…&sig=…, and something has to render. You can’t put a Server Action behind that click, because actions are POSTs triggered by forms and buttons, not by URLs in a mailbox. So the accept URL has to land on a page: app/accept-invite/page.tsx, a Server Component that reads the query string, verifies it, looks up the row, and decides what to show.

There is one tempting instinct to head off before it costs you a support ticket. Reading “the link accepts the invite” literally, you might expect clicking to do the accepting: land on the page, write the member row, flip the invitation to accepted, done. It feels right, but it is wrong, because Bob’s browser is not the only thing that fetches that URL. An email security scanner fetches every link in an inbound message to check it for malware. A link unfurler fetches it to draw a preview thumbnail. A corporate URL rewriter fetches it before the click ever leaves the network. Each one is a silent GET, and the trap below lets each one accept Bob’s invite for him.

export default async function AcceptInvitePage({
searchParams,
}: { searchParams: Promise<{ id: string; token: string; sig: string }> }) {
const { id, token, sig } = await searchParams;
const invitation = await loadVerifiedInvitation(id, token, sig);
await db.insert(member).values({ userId, role: invitation.role });
await db.update(invitation).set({ status: 'accepted' });
return <p>You're in.</p>;
}

The render writes. On render, this page inserts the member and flips the status. A scanner or unfurler always reaches the URL before Bob does, so the first one to arrive consumes the invitation. Bob clicks three days later and gets “you’re already a member” for a seat a bot claimed. A GET is supposed to be safe to repeat with no side effect; writing to the database on render breaks that contract, and the bots will find the break.

That split is the spine of the whole lesson: the GET decides, the POST writes. The page render reads the URL and chooses what to show. The accept action, behind an explicit button, is the only thing that touches the member table. Every other decision in this lesson follows from that one.

Before the page can show Bob anything, it has to decide whether to trust the URL at all. A GET /accept-invite?… is just a string a stranger handed you; the query params could be tampered with, stale, already spent, or pointing at a row that never existed. The page runs a fixed sequence of checks, and the order is what matters: each step is cheaper than the next, and the first failure stops the line.

You already have the first check from the previous lesson. verifyInviteUrl(id, token, sig) was a teaching stub then, a way to prove the sign-and-verify halves agreed. Here it becomes the production gate. It recomputes the HMAC over the canonical `${id}.${token}` payload and compares it to the sig from the URL with crypto.subtle.verify, never ===, because a character-by-character string compare leaks timing and hands an attacker a side channel. If the signature doesn’t check out, the URL is forged or mangled, and you refuse without ever touching the database. That is the whole point of the signature: it is a doorman who turns away bad URLs before they cost you a query.

const { id, token, sig } = await searchParams;
if (!(await verifyInviteUrl(id, token, sig))) {
return <InviteRefused />;
}
const invitation = await getInvitationById(id);
if (!invitation) {
return <InviteRefused />;
}
if (!(await tokenMatches(token, invitation.tokenHash))) {
return <InviteRefused />;
}
if (invitation.expiresAt < new Date()) {
return <InviteExpired email={invitation.email} />;
}
switch (invitation.status) {
case 'pending':
return <AcceptDecision invitation={invitation} />;
case 'accepted':
return <AlreadyMember orgName={invitation.orgName} />;
case 'canceled':
return <InviteRevoked />;
case 'rejected':
return <InviteRefused />;
}

Check the signature first, because it is the only check that needs no database. verifyInviteUrl recomputes the HMAC and constant-time-compares it to sig. A forged or tampered URL fails here and renders the generic refusal, having spent zero queries.

const { id, token, sig } = await searchParams;
if (!(await verifyInviteUrl(id, token, sig))) {
return <InviteRefused />;
}
const invitation = await getInvitationById(id);
if (!invitation) {
return <InviteRefused />;
}
if (!(await tokenMatches(token, invitation.tokenHash))) {
return <InviteRefused />;
}
if (invitation.expiresAt < new Date()) {
return <InviteExpired email={invitation.email} />;
}
switch (invitation.status) {
case 'pending':
return <AcceptDecision invitation={invitation} />;
case 'accepted':
return <AlreadyMember orgName={invitation.orgName} />;
case 'canceled':
return <InviteRevoked />;
case 'rejected':
return <InviteRefused />;
}

Now load the row by id. No row means a deleted or fabricated id, so render the same refusal, never a distinct “not found”. A distinct message would confirm to a prober that some ids exist and others don’t.

const { id, token, sig } = await searchParams;
if (!(await verifyInviteUrl(id, token, sig))) {
return <InviteRefused />;
}
const invitation = await getInvitationById(id);
if (!invitation) {
return <InviteRefused />;
}
if (!(await tokenMatches(token, invitation.tokenHash))) {
return <InviteRefused />;
}
if (invitation.expiresAt < new Date()) {
return <InviteExpired email={invitation.email} />;
}
switch (invitation.status) {
case 'pending':
return <AcceptDecision invitation={invitation} />;
case 'accepted':
return <AlreadyMember orgName={invitation.orgName} />;
case 'canceled':
return <InviteRevoked />;
case 'rejected':
return <InviteRefused />;
}

Re-hash the incoming token and compare it against the stored tokenHash, timing-safe. A mismatch means the signed id and the token don’t belong together, so it gets the same generic refusal again.

const { id, token, sig } = await searchParams;
if (!(await verifyInviteUrl(id, token, sig))) {
return <InviteRefused />;
}
const invitation = await getInvitationById(id);
if (!invitation) {
return <InviteRefused />;
}
if (!(await tokenMatches(token, invitation.tokenHash))) {
return <InviteRefused />;
}
if (invitation.expiresAt < new Date()) {
return <InviteExpired email={invitation.email} />;
}
switch (invitation.status) {
case 'pending':
return <AcceptDecision invitation={invitation} />;
case 'accepted':
return <AlreadyMember orgName={invitation.orgName} />;
case 'canceled':
return <InviteRevoked />;
case 'rejected':
return <InviteRefused />;
}

Only now check the time. An expired invite is a normal end-of-life, not an attack, so it earns its own friendlier screen: “this invite expired, ask for a new one.” Singling out expiry helps the user and reveals nothing useful to an attacker.

const { id, token, sig } = await searchParams;
if (!(await verifyInviteUrl(id, token, sig))) {
return <InviteRefused />;
}
const invitation = await getInvitationById(id);
if (!invitation) {
return <InviteRefused />;
}
if (!(await tokenMatches(token, invitation.tokenHash))) {
return <InviteRefused />;
}
if (invitation.expiresAt < new Date()) {
return <InviteExpired email={invitation.email} />;
}
switch (invitation.status) {
case 'pending':
return <AcceptDecision invitation={invitation} />;
case 'accepted':
return <AlreadyMember orgName={invitation.orgName} />;
case 'canceled':
return <InviteRevoked />;
case 'rejected':
return <InviteRefused />;
}

Finally, branch on status. pending proceeds to the four-shape decision. accepted is a friendly “you’re already a member” landing, almost always a double-click rather than an error. canceled says the invite was revoked. rejected falls back to the generic refusal.

1 / 1

Look at where the screens converge and where they fork. A bad signature, a missing row, and a token-hash mismatch all collapse into one refusal screen, and that is deliberate. To Bob, those three failures mean the same thing, “my link is no good, I need a fresh invite,” so they get the same recovery copy. To an attacker, telling them which of the three failed is a gift: “not found” versus “tampered” leaks whether a row exists, which is exactly what you don’t want a prober mapping. One refusal string, zero enumeration, no cost to the user.

Expiry and revocation fork off because they are honest, distinct situations with their own recovery. An expired invite genuinely was valid once, and the fix is “ask for a new one.” A revoked invite means an admin pulled it on purpose. Both carry real information the user can act on, so spending a separate screen on each adds value rather than leaking anything. The rule underneath: differentiate a failure only when the distinction helps the user more than it helps an attacker.

One more reflex to bank, and it comes straight from the codebase’s error-handling stance: a gate treats an exception as a refusal. If verifyInviteUrl throws, whether on bad base64 in sig, a malformed key, or anything else, the catch defaults to deny. A check that controls access fails closed; it never falls through to “I couldn’t decide, so let them in.”

A request hits /accept-invite and the sig fails verifyInviteUrl. Which screen should the page render?

A 404 reading “invitation not found”.
A page that warns the visitor their link appears to have been tampered with.
The same generic refusal shown for a missing row or a bad token hash.
The Accept button — let acceptInvitation reject the bad signature when the form posts.

The gate has passed and the status is pending. Now for the payoff. The page has to render one of four UI states, and it picks the state from two facts it can read on the server: is there a session, and does the session’s email match invitation.email? Walk it the way the server does, one question at a time. Each branch below is a real decision the page code makes, so click through it.

Routing one accept URL

Notice what the walker just walked you through: the server’s exact question order, and the fact that three of the four shapes are really detours back to the first one. Shape A is the only state where the Accept button appears, because it is the only state where the right person is authenticated as the invited email. C and D both have to reach Shape A first, by signing in or signing up and then returning. B is the dead end.

The reflex to carry out of this section: the routing decision is made on the server, from the row and the session, never from the query string’s display values. It is tempting to render the org name straight from a ?org=Acme param, but a query param is attacker-controlled text, and reflecting it into the page is a cross-site-scripting hole. The trustworthy source for anything you display, whether the org name, the role, or the invited email, is the row you loaded by id after the gate passed. The URL’s job is to identify the invitation; the row’s job is to describe it.

Shapes C and D reuse the sign-in and sign-up actions you already built, so there is nothing new in how they authenticate. This flow adds exactly two things. First, prefill the email from invitation.email, so Bob isn’t retyping the address the invite already knows. Second, carry next back to the accept URL, so the moment he is authenticated he lands right back where he was instead of on some generic dashboard. That next runs through the same open-redirect guard every return URL does, so it can only point inside your own app.

Shape D needs one extra rule, because skipping it generates real support tickets. Lock the sign-up email to the invited address: render it readonly, and don’t let Bob edit it. If he can change the email at the sign-up prompt, he will occasionally sign up as bob.personal@gmail.com, bounce straight into the mismatch branch, and write you a confused message about a broken invite. Prefilling and locking the field is the structural fix: the only account this form can create is one whose email matches the invitation, which is the only account that can accept it.

The accept action writes once, behind the click

Section titled “The accept action writes once, behind the click”

Bob is in Shape A, the button is on screen, and he presses it. Now acceptInvitation runs, the one place in this whole flow that writes the member row. Two decisions shape it, and both are the kind beginners get wrong, so it is worth framing them up front.

It is not wrapped in authedAction. That wrapper, from the RBAC chapter, checks a caller’s role against their membership in the org before letting the action body run. But Bob has no membership yet, which is the entire point of accepting, so there is no role to gate on. The authority that lets this action proceed isn’t an org role; it is the invitation token itself. So acceptInvitation does its own authorization by hand: re-verify the token, then confirm the signed-in user’s email equals the invited email.

It re-verifies the URL, even though the page already did. The page’s verification ran during a GET, possibly seconds or minutes ago. The button press is a separate POST: a brand-new request, with its own session that might have changed, carrying form inputs a script could have tampered with before submitting. The page’s pre-check is not authorization for the write; it never traveled to this request. So the action re-runs the signature, the hash, the expiry, and the status = 'pending' check itself, from scratch.

'use server';
export async function acceptInvitation(formData: FormData) {
const { id, token } = acceptInvitationSchema.parse(
Object.fromEntries(formData),
);
const user = await getCurrentUser();
const invitation = await getInvitationById(id);
if (
!invitation ||
!(await verifyInviteToken(id, token, invitation.tokenHash)) ||
invitation.expiresAt < new Date() ||
invitation.status !== 'pending'
) {
return err('not_found', 'This invitation is no longer valid.');
}
if (!user || user.email !== invitation.email) {
return err('forbidden', 'This invitation was sent to a different address.');
}
await withTenant(invitation.organizationId, async (tx) => {
const [newMember] = await tx
.insert(member)
.values({ userId: user.id, role: invitation.role })
.returning({ id: member.id });
await tx
.update(invitation)
.set({ status: 'accepted', acceptedAt: new Date() })
.where(and(eq(invitation.id, id), eq(invitation.status, 'pending')));
if (!user.emailVerified) {
// the invite is the email-ownership proof
await tx
.update(userTable)
.set({ emailVerified: true })
.where(eq(userTable.id, user.id));
}
await auth.api.setActiveOrganization({
headers: await headers(),
organizationId: invitation.organizationId,
});
await logAudit(tx, {
action: 'invitation.accepted',
subjectType: 'invitation',
subjectId: id,
payload: { newMemberId: newMember.id, role: invitation.role },
});
});
redirect('/dashboard');
}

Re-verify the URL from scratch: signature, token hash, expiry, and status !== 'pending', all re-checked inside the action. Any failure returns a generic not_found Result. The page’s earlier check does not carry here, because this is a new request.

'use server';
export async function acceptInvitation(formData: FormData) {
const { id, token } = acceptInvitationSchema.parse(
Object.fromEntries(formData),
);
const user = await getCurrentUser();
const invitation = await getInvitationById(id);
if (
!invitation ||
!(await verifyInviteToken(id, token, invitation.tokenHash)) ||
invitation.expiresAt < new Date() ||
invitation.status !== 'pending'
) {
return err('not_found', 'This invitation is no longer valid.');
}
if (!user || user.email !== invitation.email) {
return err('forbidden', 'This invitation was sent to a different address.');
}
await withTenant(invitation.organizationId, async (tx) => {
const [newMember] = await tx
.insert(member)
.values({ userId: user.id, role: invitation.role })
.returning({ id: member.id });
await tx
.update(invitation)
.set({ status: 'accepted', acceptedAt: new Date() })
.where(and(eq(invitation.id, id), eq(invitation.status, 'pending')));
if (!user.emailVerified) {
// the invite is the email-ownership proof
await tx
.update(userTable)
.set({ emailVerified: true })
.where(eq(userTable.id, user.id));
}
await auth.api.setActiveOrganization({
headers: await headers(),
organizationId: invitation.organizationId,
});
await logAudit(tx, {
action: 'invitation.accepted',
subjectType: 'invitation',
subjectId: id,
payload: { newMemberId: newMember.id, role: invitation.role },
});
});
redirect('/dashboard');
}

The email guard. Confirm the signed-in user is the invited person, server-side. The UI must never be the only thing stopping the mismatch shape, so this is the real gate.

'use server';
export async function acceptInvitation(formData: FormData) {
const { id, token } = acceptInvitationSchema.parse(
Object.fromEntries(formData),
);
const user = await getCurrentUser();
const invitation = await getInvitationById(id);
if (
!invitation ||
!(await verifyInviteToken(id, token, invitation.tokenHash)) ||
invitation.expiresAt < new Date() ||
invitation.status !== 'pending'
) {
return err('not_found', 'This invitation is no longer valid.');
}
if (!user || user.email !== invitation.email) {
return err('forbidden', 'This invitation was sent to a different address.');
}
await withTenant(invitation.organizationId, async (tx) => {
const [newMember] = await tx
.insert(member)
.values({ userId: user.id, role: invitation.role })
.returning({ id: member.id });
await tx
.update(invitation)
.set({ status: 'accepted', acceptedAt: new Date() })
.where(and(eq(invitation.id, id), eq(invitation.status, 'pending')));
if (!user.emailVerified) {
// the invite is the email-ownership proof
await tx
.update(userTable)
.set({ emailVerified: true })
.where(eq(userTable.id, user.id));
}
await auth.api.setActiveOrganization({
headers: await headers(),
organizationId: invitation.organizationId,
});
await logAudit(tx, {
action: 'invitation.accepted',
subjectType: 'invitation',
subjectId: id,
payload: { newMemberId: newMember.id, role: invitation.role },
});
});
redirect('/dashboard');
}

Insert the member row. The role is invitation.role, the inviter’s snapshotted choice, never re-prompted at accept time. .returning hands back the new member’s id for the audit payload.

'use server';
export async function acceptInvitation(formData: FormData) {
const { id, token } = acceptInvitationSchema.parse(
Object.fromEntries(formData),
);
const user = await getCurrentUser();
const invitation = await getInvitationById(id);
if (
!invitation ||
!(await verifyInviteToken(id, token, invitation.tokenHash)) ||
invitation.expiresAt < new Date() ||
invitation.status !== 'pending'
) {
return err('not_found', 'This invitation is no longer valid.');
}
if (!user || user.email !== invitation.email) {
return err('forbidden', 'This invitation was sent to a different address.');
}
await withTenant(invitation.organizationId, async (tx) => {
const [newMember] = await tx
.insert(member)
.values({ userId: user.id, role: invitation.role })
.returning({ id: member.id });
await tx
.update(invitation)
.set({ status: 'accepted', acceptedAt: new Date() })
.where(and(eq(invitation.id, id), eq(invitation.status, 'pending')));
if (!user.emailVerified) {
// the invite is the email-ownership proof
await tx
.update(userTable)
.set({ emailVerified: true })
.where(eq(userTable.id, user.id));
}
await auth.api.setActiveOrganization({
headers: await headers(),
organizationId: invitation.organizationId,
});
await logAudit(tx, {
action: 'invitation.accepted',
subjectType: 'invitation',
subjectId: id,
payload: { newMemberId: newMember.id, role: invitation.role },
});
});
redirect('/dashboard');
}

Flip the invitation to accepted. The .where clause carries a status = 'pending' precondition: the update only lands if the row is still pending. One update wins; a racing second one matches zero rows. This clause does double duty, and “Orphans, mismatches, and the double-click race” comes back to it.

'use server';
export async function acceptInvitation(formData: FormData) {
const { id, token } = acceptInvitationSchema.parse(
Object.fromEntries(formData),
);
const user = await getCurrentUser();
const invitation = await getInvitationById(id);
if (
!invitation ||
!(await verifyInviteToken(id, token, invitation.tokenHash)) ||
invitation.expiresAt < new Date() ||
invitation.status !== 'pending'
) {
return err('not_found', 'This invitation is no longer valid.');
}
if (!user || user.email !== invitation.email) {
return err('forbidden', 'This invitation was sent to a different address.');
}
await withTenant(invitation.organizationId, async (tx) => {
const [newMember] = await tx
.insert(member)
.values({ userId: user.id, role: invitation.role })
.returning({ id: member.id });
await tx
.update(invitation)
.set({ status: 'accepted', acceptedAt: new Date() })
.where(and(eq(invitation.id, id), eq(invitation.status, 'pending')));
if (!user.emailVerified) {
// the invite is the email-ownership proof
await tx
.update(userTable)
.set({ emailVerified: true })
.where(eq(userTable.id, user.id));
}
await auth.api.setActiveOrganization({
headers: await headers(),
organizationId: invitation.organizationId,
});
await logAudit(tx, {
action: 'invitation.accepted',
subjectType: 'invitation',
subjectId: id,
payload: { newMemberId: newMember.id, role: invitation.role },
});
});
redirect('/dashboard');
}

Mark the email verified, but only if it wasn’t already, which is exactly the case where Bob signed up through this invite. The admin sent the invite to that address and the click proves it is reachable, so the invite is the email-ownership proof. Without this, a fresh invitee bounces straight off the verify-required check after confirming their address through the invite, which leaves them stuck in a loop.

'use server';
export async function acceptInvitation(formData: FormData) {
const { id, token } = acceptInvitationSchema.parse(
Object.fromEntries(formData),
);
const user = await getCurrentUser();
const invitation = await getInvitationById(id);
if (
!invitation ||
!(await verifyInviteToken(id, token, invitation.tokenHash)) ||
invitation.expiresAt < new Date() ||
invitation.status !== 'pending'
) {
return err('not_found', 'This invitation is no longer valid.');
}
if (!user || user.email !== invitation.email) {
return err('forbidden', 'This invitation was sent to a different address.');
}
await withTenant(invitation.organizationId, async (tx) => {
const [newMember] = await tx
.insert(member)
.values({ userId: user.id, role: invitation.role })
.returning({ id: member.id });
await tx
.update(invitation)
.set({ status: 'accepted', acceptedAt: new Date() })
.where(and(eq(invitation.id, id), eq(invitation.status, 'pending')));
if (!user.emailVerified) {
// the invite is the email-ownership proof
await tx
.update(userTable)
.set({ emailVerified: true })
.where(eq(userTable.id, user.id));
}
await auth.api.setActiveOrganization({
headers: await headers(),
organizationId: invitation.organizationId,
});
await logAudit(tx, {
action: 'invitation.accepted',
subjectType: 'invitation',
subjectId: id,
payload: { newMemberId: newMember.id, role: invitation.role },
});
});
redirect('/dashboard');
}

Switch Bob’s active org to Acme. He was in his Personal org a second ago and just clicked an Acme link, so his intent is unambiguous. The alternative, leaving him in Personal to switch manually, is worse UX for no gain.

'use server';
export async function acceptInvitation(formData: FormData) {
const { id, token } = acceptInvitationSchema.parse(
Object.fromEntries(formData),
);
const user = await getCurrentUser();
const invitation = await getInvitationById(id);
if (
!invitation ||
!(await verifyInviteToken(id, token, invitation.tokenHash)) ||
invitation.expiresAt < new Date() ||
invitation.status !== 'pending'
) {
return err('not_found', 'This invitation is no longer valid.');
}
if (!user || user.email !== invitation.email) {
return err('forbidden', 'This invitation was sent to a different address.');
}
await withTenant(invitation.organizationId, async (tx) => {
const [newMember] = await tx
.insert(member)
.values({ userId: user.id, role: invitation.role })
.returning({ id: member.id });
await tx
.update(invitation)
.set({ status: 'accepted', acceptedAt: new Date() })
.where(and(eq(invitation.id, id), eq(invitation.status, 'pending')));
if (!user.emailVerified) {
// the invite is the email-ownership proof
await tx
.update(userTable)
.set({ emailVerified: true })
.where(eq(userTable.id, user.id));
}
await auth.api.setActiveOrganization({
headers: await headers(),
organizationId: invitation.organizationId,
});
await logAudit(tx, {
action: 'invitation.accepted',
subjectType: 'invitation',
subjectId: id,
payload: { newMemberId: newMember.id, role: invitation.role },
});
});
redirect('/dashboard');
}

Write the audit row. The actor is Bob, so this records him accepting, distinct from the previous lesson’s 'invitation.sent' row that recorded Alice’s intent. Two rows, two moments, two actors. The payload carries the new newMemberId, which is the only link between the invitation and the member it became.

1 / 1

Two orderings in that action must never flip, and they are the same COMMIT pivot from the previous lesson. Everything inside withTenant shares one transaction: the member insert, the status flip, the email-verified write, the active-org switch, and the audit row all commit together or not at all. The redirect('/dashboard') fires after that commit returns. Redirect before the active-org write has committed and Bob lands on a dashboard that doesn’t yet know he is in Acme, which produces the kind of bug that disappears on a refresh and is miserable to debug.

Notice the redirect target: a plain /dashboard, not a next param. Signing in carries a next because the user was headed somewhere and got bounced to sign in. Accepting an invite is its own intent with its own destination, the org you just joined. The protected layout resolves /dashboard inside Bob’s now-active Acme context, so he lands in the right tenant by construction. There is nothing to thread through a query string.

This is also why optimistic concurrency earns its keep here, and why the flow is safe to click twice. The status = 'pending' precondition means the action never assumes the row is still acceptable at write time. It lets the database decide, atomically, which of two competing accepts wins.

Shape D is the only arrival with a loop, and seeing the loop answers the question “how does a brand-new user, who has no account and no session, ever reach an Accept button?” Shape D doesn’t have its own write path; it routes Bob back through the front door until he is in Shape A. Scrub through the round trip.

Click
GET · Shape D
Sign up
next → accept URL
Back to accept URL
now Shape A
Accept
writes member

Signed out, no account. GET /accept-invite?… passes the verify gate and lands on Shape D, a sign-up form email-locked to the invited address.

Click
GET · Shape D
Sign up
next → accept URL
Back to accept URL
now Shape A
Accept
writes member

Bob submits the sign-up form. It runs the existing sign-up action with next set to this same accept URL. An account and a session are created.

Click
GET · Shape D
Sign up
next → accept URL
Back to accept URL
now Shape A
Accept
writes member

The browser returns to /accept-invite?…. This time there is a session, and its email matches the invitation, so the page renders Shape A, the Accept button.

Click
GET · Shape D
Sign up
next → accept URL
Back to accept URL
now Shape A
Accept
writes member

Bob presses Accept. acceptInvitation runs: it writes the member row, marks his email verified, switches his active org to Acme, logs the audit row, and redirects to /dashboard.

The judgment call this sequence embodies: even immediately after sign-up, render the Accept button rather than auto-accepting on his behalf. It is tempting to save him a click, since he just proved his identity, but the click is the consent signal, and Bob should see “you’re about to join Acme” exactly once, on purpose, before he is a member. The cost is one extra click; the gain is that he knows what he joined. It also keeps the write path honest: Shape D doesn’t get a special “sign up and accept in one shot” branch that duplicates the action. D collapses into A, and A is the only place the write fires.

You have now seen every screen and the one write. The last thing to settle is what happens when the link gets clicked more than once, because it will. People double-click. They reopen the tab they left open yesterday. They forward the email to themselves and click the new copy. The flow has to stay calm under all of it, and you have already built every piece that makes it safe.

The link clicked a second time, already accepted. The second GET runs the same verify ladder, reaches the status switch, and finds status = 'accepted'. That is not an error; it is the friendly “you’re already a member of Acme” landing with a link to the dashboard. The most common cause is a double-click or a stale tab, and the right response is to quietly deliver the person where they were going rather than scolding them with an error page.

Two tabs, two simultaneous Accepts. Bob has the page open twice and clicks Accept in both within the same second. Both POSTs enter acceptInvitation, both pass verification, and both try to flip the invitation. This is where the where status = 'pending' precondition does its job: the database lets exactly one update match the row, and the other matches zero rows. One tab gets the new member and the redirect; the other resolves harmlessly into the already-a-member branch. The point to internalize: never assume the row is still pending when you write. The where clause is the guard that makes the race a non-event, and that is what makes this whole flow idempotent . The full walk-through of that race, the complete sequence and who wins and why, is the job of “Orphans, mismatches, and the double-click race”; here it is enough to know the guard is in place.

Each claim is about how the accept flow behaves when a link is clicked more than once. Mark each statement True or False.

Clicking an already-accepted invitation link a second time renders an error page.

False. It renders the friendly “you’re already a member” landing with a dashboard link. A second click is almost always a double-click or a reopened tab — the flow delivers the person where they were going, not an error.

When two Accept submissions race, the where status = 'pending' clause on the update lets only one of them succeed.

True. The first update matches the still-pending row; the second matches zero rows and resolves into the already-a-member branch. The precondition is the concurrency guard — that’s why the flow is idempotent.

Rendering the accept page writes the member row.

False. The render is a pure read-and-decide that produces a button. The write happens only in acceptInvitation, behind the consent click — never on a GET, which scanners and unfurlers also issue.

You have turned one URL into four authentication situations, gated all of them behind a single verify ladder that refuses as one, and written the member row exactly once: behind a consent click, re-verified in the action, and safe to click twice. The mental model to keep is that the accept URL is a key. Clicking it unlocks a decision page, not a door. The door opens only when the right person, authenticated as the invited email, presses Accept, and the action checks the key again on the way through.

The Better Auth pages below are the source of truth for the two organization-plugin calls this flow leans on, and the MDN page grounds the “a GET must not write” argument that the whole accept-page shape is built on.