Skip to content
Chapter 58Lesson 3

The invitation accept flow

Build the accept page and Server Action that route every arrival into one organization membership.

Bob opens Alice’s invite email and clicks the link. Who is Bob at that click? He might be signed in as bob@acme.com, signed in as another account, signed out with an account waiting for him, or signed out with no account at all. Four people behind one URL, and the invitation row must become a member row for exactly one of them: signed in as the invited address, and having said yes.

The previous lesson sent the link; this lesson builds what it opens. You’ll write an /accept-invite page that routes all four arrivals, and an acceptInvitation action that writes the member row only behind a consent click and stays safe when Bob double-clicks.

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. A Server Action can’t sit behind that click, because actions are POSTs triggered by forms and buttons, not URLs in a mailbox. So the URL lands 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.

Read “the link accepts the invite” literally and you’d expect the click itself to do the accepting: write the member row, flip the invitation to accepted, done. But Bob’s browser isn’t the only thing that fetches the 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 leaves the network. Each 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. This page inserts the member and flips the status as it renders. A scanner or unfurler reaches the URL before Bob does, so the first 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, and writing on render breaks that contract — the bots will find the break.

This split runs through the whole lesson: the GET decides, the POST writes. Only the accept action, behind an explicit button, touches the member table.

Before the page shows Bob anything, it decides whether to trust the URL. A GET /accept-invite?… is a string a stranger handed you, so the page runs a fixed sequence of checks. Order matters: each step is cheaper than the next, and the first failure stops the line.

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: the only check that needs no database. verifyInviteUrl recomputes the HMAC and constant-time-compares it to sig, never ===, since a character-by-character compare leaks timing. A forged URL fails here, 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” that would confirm to a prober which ids exist.

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 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 normal end-of-life, not an attack, so it earns its own friendlier screen: “this invite expired, ask for a new one.”

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 lands on “you’re already a member,” almost always a double-click. canceled says the invite was revoked. rejected falls back to the generic refusal.

1 / 1

A bad signature, a missing row, and a token-hash mismatch collapse into one refusal. To Bob they mean the same thing, “my link is no good, I need a fresh invite.” To an attacker, naming which check failed leaks whether a given row exists, exactly what a prober wants.

Expiry and revocation fork off because they are honest, distinct situations the user can act on: an expired invite needs a new request, a revoked one means an admin pulled it on purpose. A separate screen adds value rather than leaking anything. The rule: differentiate a failure only when the distinction helps the user more than it helps an attacker.

A gate also treats an exception as a refusal. If verifyInviteUrl throws, on bad base64 in sig or anything else, the catch defaults to deny. A check that controls access fails closed; it never lets Bob in because it couldn’t decide.

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. The page now renders one of four UI states, chosen from two facts it reads on the server: is there a session, and does the session’s email match invitation.email? The walker shows the order the server asks them in.

Routing one accept URL

Shape A is the only state where the Accept button appears, because it is the only state where the invited person is the one authenticated. C and D have to reach it first by signing in or signing up and returning; B is the dead end.

Make the routing decision on the server, from the row and the session, never from the query string’s display values. Rendering the org name straight from a ?org=Acme param reflects attacker-controlled text into the page, 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 identifies the invitation; the row describes it.

Shapes C and D reuse the sign-in and sign-up actions you already built, so the authentication is nothing new. This flow adds two things: prefill the email from invitation.email, and carry next back to the accept URL so the moment Bob is authenticated he lands where he was instead of on a generic dashboard. That next runs through the same open-redirect guard as every return URL, so it can only point inside your own app.

Shape D needs one extra rule: render the sign-up email readonly, locked to the invited address. If Bob can change it, he will occasionally sign up as bob.personal@gmail.com, drop into the mismatch branch, and file a confused ticket about a broken invite. Locking the field makes the only account this form can create 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, sees the button, and presses it. acceptInvitation runs, the one place in this flow that writes the member row. Two decisions shape it, and both are easy to get wrong.

It is not wrapped in authedAction. That wrapper checks a caller’s role against their membership before running the body. But Bob has no membership yet, which is the whole point of accepting, so there is no role to gate on. The authority here is not an org role; it is the invitation token itself. So acceptInvitation authorizes by hand: re-verify the token, then confirm the signed-in user’s email matches the invited email.

It re-verifies the URL, even though the page already did. The page checked during a GET, possibly minutes ago. The button press is a separate POST, a new request with its own session and form inputs a script could have tampered with. So the action re-runs the signature, hash, expiry, and status = 'pending' check 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'. Any failure returns a generic not_found Result. This is a new request, so the page’s earlier check does not carry here.

'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, checked server-side: the signed-in user must be the invited person. 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 carries a status = 'pending' precondition, so the update lands only if the row is still pending: one update wins, a racing second one matches zero rows. “Invite edge cases” comes back to this clause.

'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 it to that address and the click proves it is reachable, so the invite is the email-ownership proof. Without this, a fresh invitee bounces off the verify-required check right after confirming their address, 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 clear. 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, recording 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 newMemberId, the only link between the invitation and the member it became.

1 / 1

Everything inside withTenant shares one transaction: the member insert, the status flip, the email-verified write, the active-org switch, and the audit row commit together or not at all. The redirect('/dashboard') fires only after that commit returns. Redirect first and Bob lands on a dashboard that doesn’t yet know he is in Acme, a bug that vanishes on refresh and is miserable to debug.

The redirect target is a plain /dashboard, not a next param. Sign-in carries a next because the user was headed somewhere and got bounced; 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.

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

Shape D is the only arrival with no write path of its own. A brand-new user with no account and no session can’t reach an Accept button, so the page routes Bob through sign-up until he returns as Shape A.

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 form. It runs the existing sign-up action with next set to this same accept URL, creating an account and a session.

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?…. Now a session’s 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 rule the sequence embodies: even immediately after sign-up, render the Accept button rather than accepting on his behalf. The click is the consent signal; Bob should see “you’re about to join Acme” once, on purpose, before he is a member. Shape D gets no special “sign up and accept in one shot” branch: it collapses into A, and A is the only place the write fires.

Links get clicked more than once: people double-click, reopen yesterday’s tab, forward the email to themselves. Every piece that keeps the flow safe under this is already built.

The link clicked again, 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. A double-click or stale tab should deliver the person where they were going, not scold them.

Two tabs, two simultaneous Accepts. Bob has the page open twice and clicks Accept in both within the same second. Both POSTs enter acceptInvitation, pass verification, and try to flip the invitation. The where status = 'pending' precondition decides it: one update matches the row, the other matches zero rows. One tab gets the new member and the redirect; the other resolves into the already-a-member branch. The lesson: never assume the row is still pending when you write. That guard is what makes the flow idempotent . The full race, who wins and why, is the job of “Invite edge cases.”

The mental model to keep: the accept URL is a key, and 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 page documents the two organization-plugin calls this flow leans on; the MDN pages ground the “a GET must not write” rule and the HMAC-verify call the gate runs.