Organization Invitations
Passkey-first onboarding for invited organization members.
Overview
Organization invitations use Better Auth for membership state, but Yayaw owns the signed-out onboarding experience. This is necessary because Better Auth invitation acceptance requires an authenticated session whose email matches the invitation.
The invitation email links to:
/auth/accept-invitation?invitationId=<id>&token=<secret>invitationId is the Better Auth invitation id. token is a Yayaw onboarding
secret generated when the email is sent. Only the token hash is stored in
invitation_onboarding_tokens; the raw token only appears in the emailed link.
Flow
Signed-out users first see a secure invitation preview backed by the onboarding token. The preferred path is passkey:
- Existing users can sign in with an existing passkey.
- New users can create an account with a passkey from the invite link.
- Magic link remains available and is locked to the invited email.
- Password signup/sign-in remains available and preserves the invitation return URL through email verification and reset flows.
Signed-in users review the invitation and explicitly accept or reject it. Better Auth verifies that the signed-in email matches the invitation recipient before acceptance. After acceptance, Better Auth sets the active organization and Yayaw marks the onboarding token consumed.
Runtime route:
/auth/accept-invitation
Key services:
src/lib/server/services/auth/invitation-onboarding.tssrc/lib/server/services/auth/invitation-onboarding-utils.tssrc/lib/server/services/email/organization-invitation-email.ts
Passkey-First Signup
Passkey-first signup uses Better Auth passkey registration with
registration.requireSession: false.
Yayaw does not pass the raw invitation token into the WebAuthn context. Instead, the client asks the server for a short-lived opaque passkey context after the email token has already been validated. The passkey registration resolver then checks that:
- the context is still valid
- the invitation is still pending
- the original onboarding token has not been consumed
- no user already exists for the invited email
Existing accounts cannot add a passkey from only an invitation link. They must authenticate first, then add a passkey after accepting the invitation.
Token Storage
The raw onboarding token is never persisted. The database stores:
- invitation id
- token hash
- invited email
- consumed timestamp
- expiry metadata
This makes leaked database rows insufficient to accept an invitation. Email delivery remains sensitive because the raw token exists in the emailed URL.
Email Behavior
Invitation emails use the transactional email pipeline when active database
templates and RESEND_API_KEY are available. Missing email configuration should
surface as a send failure to the operator, not as a silent membership grant.
The invite URL must remain absolute and based on NEXT_PUBLIC_BASE_URL, so
production deployments need the canonical host configured before sending real
invitations.
AuthZ Synchronization
Invitation acceptance creates a Better Auth member. Yayaw also syncs accepted invitation members into the internal authorization groups through the Better Auth organization hooks, so dashboard permissions are available immediately after joining.
Better Auth roles are mapped into organization-scoped Yayaw groups during organization creation, invitation acceptance, member role updates, and member removal. Do not manually insert Better Auth members without also preserving that sync path.
Security Rules
- Only the invited email can accept the invitation.
- Invitation preview is allowed while signed out, but membership creation still requires a verified/authenticated identity.
- Passkey context is short-lived and derived after token validation.
- Consumed tokens cannot be reused.
- Existing accounts must authenticate before adding passkeys.
- Password and magic-link fallbacks must preserve the invitation return URL.
Validation
Useful checks after invitation or auth onboarding changes:
bun test src/lib/server/services/auth/invitation-onboarding-utils.test.ts
bun run check
bunx tsc --noEmit
bun run build