Skip to main content
The invite plugin implements multiple security layers to protect against unauthorized access and abuse.

Token Security

Token Generation

The plugin uses cryptographically secure token generators from Better Auth:
// 24-character secure ID
generateId(24)
// Example: a1b2c3d4e5f6g7h8i9j0k1l2
See Custom Tokens for implementing your own token generator.

Token Uniqueness

Tokens are enforced as unique at the database level (src/schema.ts:6):
token: { type: "string", unique: true }
This prevents duplicate tokens and ensures each invitation has a unique identifier.

Secure Fallback

If you configure defaultTokenType: 'custom' without providing a generateToken function, the plugin falls back to the secure default token generator (src/utils.ts:79):
custom: () => generateId(24), // secure fallback

Token Expiration

Expiration Time

Tokens automatically expire after a configured duration:
invitationTokenExpiresIn
number
default:"3600"
Token expiration time in seconds (default: 1 hour).
invite({
  invitationTokenExpiresIn: 60 * 60 * 24 // 24 hours
})

Expiration Validation

The plugin validates token expiration when accepting invites (src/utils.ts:118-120):
if (invitation.status !== "pending" && invitation.status !== undefined) {
  throw error("BAD_REQUEST", ERROR_CODES.INVALID_TOKEN, "INVALID_TOKEN");
}
Expired tokens receive the error code INVALID_OR_EXPIRED_INVITE from src/constants.ts:9. When users are not logged in, the plugin stores the invite token in a secure cookie (src/constants.ts:21):
export const INVITE_COOKIE_NAME = "invite_token";
The cookie has a configurable maximum age:
Cookie max age in seconds (default: 10 minutes).
invite({
  inviteCookieMaxAge: 600 // 10 minutes
})
This controls how long users have to complete the login flow before the cookie expires. Better Auth sets secure cookie attributes automatically:
  • HttpOnly: Prevents JavaScript access
  • Secure: Only transmitted over HTTPS (in production)
  • SameSite: Protects against CSRF attacks

Permission Checks

The plugin implements role-based permission checks for all operations.

Can Create Invite

Verifies if a user can create invitations (src/types.ts:23-32):
invite({
  canCreateInvite: async ({ invitedUser, inviterUser, ctx }) => {
    // Only admins can create invites
    return inviterUser.role === 'admin';
  }
})
Error code: INSUFFICIENT_PERMISSIONS (src/constants.ts:5-6)

Can Accept Invite

Verifies if a user can accept an invitation (src/types.ts:46-51 and src/utils.ts:122-137):
const canAcceptInviteOptions =
  typeof options.canAcceptInvite === "function"
    ? await options.canAcceptInvite({ invitedUser, newAccount })
    : options.canAcceptInvite;
const canAcceptInvite =
  typeof canAcceptInviteOptions === "object"
    ? await exports.checkPermissions(ctx, canAcceptInviteOptions)
    : canAcceptInviteOptions;

if (!canAcceptInvite) {
  throw error(
    "BAD_REQUEST",
    ERROR_CODES.CANT_ACCEPT_INVITE,
    "CANT_ACCEPT_INVITE",
  );
}
Error code: CANT_ACCEPT_INVITE (src/constants.ts:12)

Can Cancel Invite

Only the user who created the invitation can cancel it (src/types.ts:54-68):
invite({
  canCancelInvite: async ({ inviterUser, invitation, ctx }) => {
    // Additional permission check
    return inviterUser.role === 'admin';
  }
})

Can Reject Invite

Only the invitee can reject a private invitation (src/types.ts:69-84):
invite({
  canRejectInvite: async ({ inviteeUser, invitation, ctx }) => {
    // Additional permission check
    return true;
  }
})
Error code: CANT_REJECT_INVITE (src/constants.ts:13)

Admin Plugin Integration

Permission checks can integrate with the Better Auth admin plugin (src/utils.ts:245-279):
export const checkPermissions = async (
  ctx: GenericEndpointContext,
  permissions: Permissions,
) => {
  const session = ctx.context.session;
  if (!session?.session) {
    throw ctx.error("UNAUTHORIZED");
  }

  const adminPlugin = getPlugin<AdminPlugin>(
    "admin" satisfies AdminPlugin["id"],
    ctx.context,
  );

  if (!adminPlugin) {
    ctx.context.logger.error("Admin plugin is not set-up.");
    throw ctx.error("FAILED_DEPENDENCY", {
      message: ERROR_CODES.ADMIN_PLUGIN_IS_NOT_SET_UP,
    });
  }

  try {
    return await adminPlugin.endpoints.userHasPermission({
      ...ctx,
      body: {
        userId: session.user.id,
        permissions: { [permissions.statement]: permissions.permissions },
      },
      returnHeaders: true,
    });
  } catch {
    return false;
  }
};
Example with admin plugin:
invite({
  canCreateInvite: {
    statement: 'invites',
    permissions: ['create']
  }
})
Error code: ADMIN_PLUGIN_IS_NOT_SET_UP (src/constants.ts:16)

Email Validation

Private invitations validate that the accepting user’s email matches the invitation (src/utils.ts:114-116):
if (invitation.email && invitation.email !== invitedUser.email) {
  throw error("BAD_REQUEST", ERROR_CODES.INVALID_EMAIL, "INVALID_EMAIL");
}
Error code: INVALID_EMAIL (src/constants.ts:11)

Status Validation

The plugin enforces invitation status before acceptance (src/utils.ts:118-120):
if (invitation.status !== "pending" && invitation.status !== undefined) {
  throw error("BAD_REQUEST", ERROR_CODES.INVALID_TOKEN, "INVALID_TOKEN");
}
Only invitations with status: 'pending' can be accepted. Other statuses include:
  • rejected: Invitation was rejected
  • canceled: Invitation was canceled
  • used: Invitation reached max uses

Usage Limits

Max Uses

Invitations have configurable usage limits:
invite({
  defaultMaxUses: 10 // Allow 10 acceptances
})
Default:
  • Private invites: 1 use
  • Public invites: Infinite uses

Usage Tracking

The plugin tracks usage in the inviteUse table and validates limits before acceptance (src/constants.ts:8):
NO_USES_LEFT_FOR_INVITE: "No uses left for this invite"

Error Codes Reference

All error codes from src/constants.ts:3-17:
USER_NOT_LOGGED_IN
string
User must be logged in to create an invite
INSUFFICIENT_PERMISSIONS
string
User does not have sufficient permissions to create invite
NO_SUCH_USER
string
No such user
NO_USES_LEFT_FOR_INVITE
string
No uses left for this invite
INVALID_OR_EXPIRED_INVITE
string
Invalid or expired invite code
INVALID_TOKEN
string
Invalid or non-existent token
INVALID_EMAIL
string
This token is for a specific email, this is not it
CANT_ACCEPT_INVITE
string
You cannot accept this invite
CANT_REJECT_INVITE
string
You cannot reject this invite
INVITER_NOT_FOUND
string
Inviter not found
ERROR_SENDING_THE_INVITATION_EMAIL
string
Error sending the invitation email
ADMIN_PLUGIN_IS_NOT_SET_UP
string
Admin plugin is not set-up

Best Practices

1. Use Short Expiration Times

Set appropriate token expiration based on your use case:
invite({
  invitationTokenExpiresIn: 60 * 15 // 15 minutes
})

2. Implement Permission Checks

Always verify user permissions before allowing invite operations:
invite({
  canCreateInvite: async ({ inviterUser }) => {
    return inviterUser.role === 'admin' || inviterUser.role === 'manager';
  },
  canAcceptInvite: async ({ invitedUser, newAccount }) => {
    // Additional validation logic
    return true;
  }
})

3. Enable Cleanup

Clean up expired and used invitations:
invite({
  cleanupInvitesAfterMaxUses: true,
  cleanupInvitesOnDecision: true
})

4. Use Private Invitations

For sensitive operations, always use email-specific invitations:
const { data } = await authClient.invite.create({
  email: '[email protected]', // Private invitation
  role: 'admin'
});

5. Monitor Failed Attempts

Use hooks to monitor and log failed invitation attempts:
invite({
  inviteHooks: {
    beforeAcceptInvite: async ({ ctx, invitedUser }) => {
      console.log(`User ${invitedUser.email} attempting to accept invite`);
    }
  }
})

6. Validate Tokens Server-Side

Always validate tokens on the server. Never trust client-side validation:
// Good: Server-side validation
const invitation = await authClient.invite.getByToken({
  token: tokenFromUrl
});

if (!invitation || invitation.status !== 'pending') {
  throw new Error('Invalid invitation');
}

7. Use HTTPS

Always serve your application over HTTPS in production to protect tokens in transit:
// Better Auth automatically enables secure cookies over HTTPS
export const auth = betterAuth({
  baseURL: process.env.BETTER_AUTH_URL, // Use https:// in production
  // ...
});

Security Checklist

1

Configure token expiration

Set invitationTokenExpiresIn based on your security requirements.
2

Implement permission checks

Configure canCreateInvite, canAcceptInvite, canCancelInvite, and canRejectInvite.
3

Use secure token generation

Use cryptographically secure generators (built-in or custom).
4

Enable cleanup

Set cleanupInvitesAfterMaxUses and cleanupInvitesOnDecision to true.
5

Validate email for private invites

Always include an email address for sensitive role assignments.
6

Monitor invitation usage

Use hooks and callbacks to track invitation activity.
7

Serve over HTTPS

Ensure your application uses HTTPS in production.
8

Set appropriate cookie max age

Configure inviteCookieMaxAge for your login flow duration.

Build docs developers (and LLMs) love