The Better Auth Invite Plugin implements a comprehensive invitation system with cookie-based activation and role management. This guide explains the complete lifecycle from invite creation to acceptance.
Invitation lifecycle
The invitation process follows a structured flow through multiple stages:
- Creation - An authorized user creates an invitation
- Distribution - The invitation is delivered via email or shareable link
- Activation - The recipient activates the invitation token
- Authentication - New users sign up or existing users sign in
- Acceptance - The invitation is consumed and the role is applied
Database architecture
The plugin uses two database tables to manage invitations:
Invite table
Stores invitation records with the following fields:
{
id: string;
token: string; // Unique invitation token
createdAt: Date;
expiresAt: Date;
maxUses: number; // How many times it can be used
createdByUserId: string; // References user.id
redirectToAfterUpgrade?: string;
shareInviterName: boolean;
email?: string; // Present for private invites
role: string; // Role to assign
newAccount?: boolean; // Whether invitee needs to create account
status: "pending" | "rejected" | "canceled" | "used";
}
Source: src/schema.ts:4-23
InviteUse table
Tracks each use of an invitation, enabling multi-use invites:
{
id: string;
inviteId: string; // References invite.id
usedAt: Date;
usedByUserId: string; // References user.id
}
Source: src/schema.ts:25-38
Cookie-based activation mechanism
The plugin uses a secure, signed cookie to handle invitation activation across the authentication flow. This is critical for new users who need to sign up before accepting an invitation.
Activation flow for unauthenticated users
- User clicks invitation link with token
- Plugin validates the token and checks:
- Token exists and hasn’t expired
- Invitation hasn’t reached max uses
- Status is “pending”
- Plugin creates a signed cookie named
invite_token containing the invitation token
- User is redirected to sign-up or sign-in page
- After authentication, the hook system detects the cookie and completes acceptance
Source: src/routes/activate-invite-logic.ts:88-104
Cookie configuration
The invitation cookie has a configurable max age:
invite({
inviteCookieMaxAge: 600, // 10 minutes (default)
});
This controls how long users have to complete the sign-in/sign-up flow before the cookie expires.
Hook execution order
The plugin provides lifecycle hooks that execute at specific points in the invitation flow:
Create invitation flow
beforeCreateInvite - Before invitation record is created
- Invitation is created in database
- Email is sent (for private invites)
afterCreateInvite - After invitation is successfully created
Source: src/routes/create-invite.ts:98 and src/routes/create-invite.ts:164
Accept invitation flow
beforeAcceptInvite - Before role upgrade is applied
- Can return modified user object to override invited user data
- User role is updated in database
- Session cookie is updated with new role
- InviteUse record is created
onInvitationUsed callback is triggered (if configured)
afterAcceptInvite - After invitation is fully processed
- User is redirected to
redirectToAfterUpgrade (if set)
Source: src/hooks.ts:96-135
Cancel invitation flow
beforeCancelInvite - Before invitation is canceled
- Invitation status updated to “canceled” or deleted (based on
cleanupInvitesOnDecision)
afterCancelInvite - After cancellation is complete
Source: src/routes/cancel-invite.ts:116-125
Reject invitation flow
beforeRejectInvite - Before invitation is rejected
- Invitation status updated to “rejected” or deleted (based on
cleanupInvitesOnDecision)
afterRejectInvite - After rejection is complete
Source: src/routes/reject-invite.ts:118-127
Authentication hook integration
The plugin automatically hooks into Better Auth’s authentication endpoints to detect and process pending invitations:
// Matches these authentication endpoints:
[
"/sign-up/email",
"/sign-in/email",
"/sign-in/email-otp",
"/callback/:id", // For social logins
"/verify-email"
]
When any of these endpoints complete successfully, the plugin:
- Checks for the
invite_token cookie
- Validates the invitation
- Applies the role upgrade
- Deletes the cookie
- Redirects the user
Source: src/hooks.ts:15-21
For social logins, the newSession is not available at the end of the initial /sign-in call, so the hook must trigger on the /callback/:id endpoint instead.
Complete flow example
Private invite for new user
// 1. Admin creates invitation
await client.invite.create({
email: "[email protected]",
role: "member",
});
// 2. Email sent with link: /invite/{token}?callbackURL=/auth/sign-up
// 3. User clicks link
// - Token validated
// - Cookie set with token
// - Redirected to /auth/sign-up
// 4. User completes sign-up
// - New account created
// - Hook detects cookie
// - Role immediately set to "member"
// - User logged in with correct role
Public invite for existing user
// 1. Admin creates shareable invitation
const { message } = await client.invite.create({
role: "member",
senderResponse: "url",
});
// message contains shareable URL
// 2. User (already logged in) visits URL
// 3. Activation endpoint
// - Token validated
// - User already authenticated
// - Role immediately upgraded to "member"
// - Session cookie updated
// - Redirected to dashboard
Role upgrade vs new account
The plugin automatically determines whether an invitation is for a new account or a role upgrade:
// Check if user exists
const invitedUser = await adapter.findUserByEmail(email);
const newAccount = !invitedUser;
// Store in invitation record
await adapter.createInvite({
// ...
newAccount, // true = sign-up, false = role upgrade
});
This information is:
- Stored in the invitation record
- Passed to email templates
- Available in permission checks
- Provided to hooks and callbacks
Source: src/routes/create-invite.ts:100-116
Security considerations
Token validation
Every activation validates:
// 1. Token exists
if (!invitation) throw error("INVALID_TOKEN");
// 2. Not expired
if (getDate() > invitation.expiresAt) throw error("INVALID_TOKEN");
// 3. Usage limit not exceeded
const timesUsed = await adapter.countInvitationUses(invitation.id);
if (!(timesUsed < invitation.maxUses)) throw error("INVALID_TOKEN");
// 4. Status is pending
if (invitation.status !== "pending") throw error("INVALID_TOKEN");
Source: src/routes/activate-invite-logic.ts:29-47
Email verification for private invites
Private invites validate the recipient’s email:
if (invitation.email && invitation.email !== invitedUser.email) {
throw error("INVALID_EMAIL");
}
Source: src/utils.ts:114-116
Signed cookies
Invitation cookies are signed with your Better Auth secret:
await ctx.setSignedCookie(
inviteCookie.name,
token,
ctx.context.secret,
inviteCookie.attributes
);
Source: src/routes/activate-invite-logic.ts:96-101
Cleanup strategies
The plugin offers two cleanup options:
Cleanup on decision
invite({
cleanupInvitesOnDecision: true,
});
When enabled, invitations and their uses are permanently deleted when canceled or rejected.
Source: src/routes/cancel-invite.ts:118-123
Cleanup after max uses
invite({
cleanupInvitesAfterMaxUses: true,
});
When enabled, invitations are deleted when they reach their maximum number of uses.
Source: src/utils.ts:161-168
If cleanup is disabled, invitations remain in the database with their status updated. This preserves audit history but requires manual cleanup.