Skip to content

Auth: email-domain matching for institutional auto-link

When a trainer signs up self-serve with an institutional email (@bit.edu.in), Kwilo checks whether that domain belongs to a verified org and offers to connect them. Org admins verify their domain once via DNS TXT, matched users get a "Join BIT?" prompt at signup, and unmatched institutional domains feed a pending-match list for sales. This closes the gap left by auth-entry-consolidation.md, where self-serve trainers at Kwilo-using institutions still landed in a personal workspace.

How it works

Two tables drive it. institutional_email_domains maps a verified domain to an organization_id (with verified_at, verification_token, added_by, added_via). pending_domain_matches records unverified institutional signups as a sales signal.

On signup with anita@bit.edu.in:

  1. If the domain is in FREE_EMAIL_DOMAINS (gmail.com, outlook.com, etc.), skip entirely.
  2. Else look up the domain in institutional_email_domains where verified_at IS NOT NULL.
  3. Hit: queue the post-signup prompt with the matched org.
  4. Miss: insert into pending_domain_matches.

Match prompt, never silent join

A trainer who matches a verified domain sees a confirmation screen after signup:

Your email matches Bangalore Institute of Technology.
Would you like to join their Kwilo workspace?
[ Yes, join BIT ]   [ Keep my personal account ]

Yes calls POST /organization-memberships { organization_id, user_id, via: 'domain_match' } and lands them in the org /dashboard, audit-logged. Keep personal creates no membership. The prompt reappears on next login until they join, and never appears for users who already belong to that org.

Learners request, they don't auto-join

A learner who matches a verified domain sees "Your school uses Kwilo" with a Request to join button (POST /organization-join-requests). A trainer or admin at that org approves before membership is created. K-12 learners are never matched, always admin-provisioned.

Domain verification (org admin)

The org admin adds a domain in /settings/domains. Kwilo shows a TXT record (Host _kwilo-verify, Value kwilo-verify=<token>) to paste into DNS. Verify now does a DNS TXT lookup against _kwilo-verify.bit.edu.in; on a token match it sets verified_at = NOW(). Existing users on that domain trigger the join prompt on their next login. Domains are UNIQUE: a second org claiming the same domain gets "already verified by another organization, contact support".

Free-email and manual paths

Free-email domains can't be verified and return a 400 explaining to verify a custom Workspace domain instead. For white-glove onboarding where DNS access is slow, a platform admin marks a domain verified via /admin/domains (added_via = 'platform_admin_manual') with a required reason in the audit log.

Pending matches feed sales

Unmatched institutional signups accumulate in pending_domain_matches. The /admin/domains dashboard aggregates them by domain so BD can see which institutions have self-serve users but no deal yet.

Where it lives

  • /signup (signup-time match), /settings/domains (org admin), /admin/domains (platform admin)
  • institutional_email_domains + pending_domain_matches tables
  • match_domain_and_queue_prompt() runs in the signup hooks (register_individual, verify_phone_otp, google_callback)
  • FREE_EMAIL_DOMAINS constant in src/core/constants.py