Email verification proves control of an inbox; it does not prove that the person should enter a property company's workspace. The safe auto-enrollment rule is therefore narrow: normalize and verify the email, resolve its domain against an explicitly verified domain registry, evaluate abuse signals, and grant membership in one atomic, replay-safe operation. Anything ambiguous goes to a non-member state. This keeps a convenient signup path from becoming a quiet tenant-data exposure path.
TL;DR: Never join on the text after @ alone. Bind a domain to exactly one workspace through an administrative verification ceremony, require a fresh email-verification event, reject public and unclaimed domains, rate-limit before expensive work, and make the membership write idempotent. For a property manager, a false negative means an employee waits for review. A false positive may expose addresses, maintenance notes, lease documents, or resident contact details. Those outcomes are not remotely symmetrical.
What page should fire when domain enrollment goes wrong?
The incident shape I care about starts with an ordinary signup: lee@northstar.example creates an account, follows the verification link, and appears in the Northstar workspace. Then a burst of newly verified accounts joins the same workspace, or one email address is processed twice after a queue retry. A dashboard can show a healthy success rate throughout. The useful page is more specific: an abnormal rate of membership grants for one workspace or domain, especially when grants come from many accounts, networks, or device fingerprints.
One bad grant is enough.
I have been woken by alerts that meant nothing and missed the one that mattered. That changes the design question. I do not ask whether the signup endpoint returned 200; I ask which authorization boundary moved, why it moved, and whether the movement can be reversed without deleting the account.
The invariant from that postmortem framing is simple: account creation, email verification, and workspace admission are separate state transitions. Verification may make an account eligible for evaluation. It cannot itself be the authorization policy. Membership must record the domain claim and policy version that justified the grant, so responders can identify affected grants and suspend them without guessing from current configuration.
Property-management systems make this distinction important. Staff turnover is common, contractors may use a management company's domain, and an acquired portfolio can retain old mail domains. A domain is organizational evidence, not a role. Auto-enrollment should grant the smallest baseline membership; access to financial exports, master keys, background checks, or cross-property resident records still needs role and property-scope assignment.
How should verified company domain signup auto-join a workspace?
The durable design has four records: account, email verification, domain claim, and workspace membership. A domain claim contains a normalized ASCII domain, a workspace identifier, a verification status, and a monotonically increasing policy version. Enforce uniqueness for active claims at the database layer. If northstar.example can resolve to two workspaces, the system must fail closed instead of picking whichever row happens to return first.
Domain ownership should be established out of band by a workspace administrator, commonly with a DNS challenge whose random token is scoped to that claim. Keep the challenge value, attempt limits, expiration, successful verification time, and later revocation in the claim lifecycle. DNS control is useful evidence of control over a domain; it does not establish employment, job function, or authorization to every property.
The signup path then becomes a small policy engine rather than a collection of string checks:
| Condition | Enrollment result | Operational reason |
|---|---|---|
| Email is not verified | No membership | Inbox control is still unknown |
| Domain has no active claim | No membership | Unknown domains must not select a workspace |
| Domain maps to multiple active claims | Stop and alert | Registry integrity has failed |
| Abuse policy challenges or blocks the attempt | Pending or denied | Verification links do not neutralize automation |
| One active claim and policy passes | Baseline membership | The authorization decision is explicit and attributable |
Do not infer the registrable organization with a hand-written “last two labels” rule. Suffixes such as co.uk break it, internationalized names need IDNA processing, and look-alike Unicode can mislead reviewers. Parse the mailbox with a maintained library, convert the domain to its canonical ASCII form, lower-case it, and compare exact normalized values from the registry. Decide separately whether subdomains inherit a claim; exact matching is safer unless administrators deliberately verify a parent policy.
Fail closed.
Put the preventative check in the transaction
The following Go sketch keeps vendor details outside the policy. It assumes the email-verification handler has authenticated a single-use, expiring token and is passing the token's server-side verification record, not trusting a client-supplied verified=true flag.
package enrollment
import (
"context"
"errors"
"strings"
"time"
)
var (
ErrNotVerified = errors.New("email is not verified")
ErrNoDomainClaim = errors.New("domain is not claimed")
ErrAmbiguous = errors.New("domain claim is ambiguous")
ErrAbuseReview = errors.New("enrollment requires review")
)
type Verification struct {
AccountID string
Email string
VerifiedAt time.Time
ConsumedAt *time.Time
}
type DomainClaim struct {
WorkspaceID string
Domain string
PolicyVersion int64
}
type RiskDecision int
const (
Allow RiskDecision = iota
Review
Deny
)
type Store interface {
WithTransaction(ctx context.Context, fn func(Tx) error) error
}
type Tx interface {
LockVerification(ctx context.Context, accountID string) (Verification, error)
FindActiveDomainClaims(ctx context.Context, domain string) ([]DomainClaim, error)
UpsertBaselineMembership(ctx context.Context, accountID, workspaceID, reason string, policyVersion int64) error
ConsumeVerification(ctx context.Context, accountID string, at time.Time) error
}
type RiskEngine interface {
Evaluate(ctx context.Context, accountID, domain string) (RiskDecision, error)
}
type Service struct {
Store Store
Risk RiskEngine
Now func() time.Time
}
func (s Service) EnrollVerifiedAccount(ctx context.Context, accountID string) error {
return s.Store.WithTransaction(ctx, func(tx Tx) error {
verification, err := tx.LockVerification(ctx, accountID)
if err != nil {
return err
}
if verification.VerifiedAt.IsZero() {
return ErrNotVerified
}
domain, err := normalizedDomain(verification.Email)
if err != nil {
return err
}
claims, err := tx.FindActiveDomainClaims(ctx, domain)
if err != nil {
return err
}
if len(claims) == 0 {
return ErrNoDomainClaim
}
if len(claims) != 1 {
return ErrAmbiguous
}
decision, err := s.Risk.Evaluate(ctx, accountID, domain)
if err != nil {
return err // Policy dependency failure is fail-closed.
}
if decision != Allow {
return ErrAbuseReview
}
claim := claims[0]
if err := tx.UpsertBaselineMembership(
ctx, accountID, claim.WorkspaceID, "verified_domain", claim.PolicyVersion,
); err != nil {
return err
}
return tx.ConsumeVerification(ctx, accountID, s.Now())
})
}
func normalizedDomain(email string) (string, error) {
at := strings.LastIndexByte(email, '@')
if at <= 0 || at == len(email)-1 {
return "", errors.New("invalid email address")
}
// Production code should apply IDNA conversion with a maintained library.
return strings.ToLower(email[at+1:]), nil
}
The database still carries the hard guarantees. Put a unique constraint on (account_id, workspace_id), and use an idempotency key tied to the verification event if event delivery can retry. Locking the verification row prevents two consumers from racing through the same event. The Upsert handles harmless replay; it must not silently upgrade an existing member's role.
The compact parser is intentionally incomplete. It marks the boundary where a standards-aware mailbox and IDNA implementation belongs, rather than pretending email syntax is solved by a regular expression. In particular, account identifiers should not be normalized by changing the local part unless the system has a documented policy for that transformation.
Abuse controls belong before and after verification
Email verification raises an attacker's cost, but automated actors can control inboxes. Rate limits should therefore use several scopes: source network, account, normalized domain, workspace, and verification attempts. A single global threshold either blocks a busy property operator or permits a distributed attack. Return generic authentication errors so signup does not become an account-discovery oracle, consistent with OWASP guidance.
Do cheap controls early. Bound request size, validate syntax, and apply coarse rate limits before sending mail or calling a risk service. After verification, evaluate signals that are relevant to the admission transition: recent grant velocity for the target workspace, repeated devices, known disposable-mail classification where legally and operationally appropriate, and failed verification history. Avoid treating any one network address as a person; apartment buildings, corporate offices, and mobile carriers routinely share addresses.
CAPTCHA can add friction during suspicious flows, but it is a challenge mechanism, not the authorization decision. Likewise, MX lookup only indicates that mail may be routed for a domain. Neither substitutes for an active domain claim and a verified mailbox.
The system needs a pressure-release state. If risk evaluation is unavailable or uncertain, create the account without membership and queue a bounded review or retry. Do not “temporarily” allow the join. Temporary access has a way of becoming the incident.
That delay is deliberate.
Test the failures responders will actually see
Happy-path unit tests are necessary and uninteresting. The release gate should exercise duplicate delivery, concurrent delivery, a claim revoked between email issuance and verification, two active claims caused by bad data, mixed-case domains, internationalized domains, expired tokens, and a risk dependency timeout. Property scope deserves its own assertion: a newly enrolled maintenance coordinator must not inherit access to every building merely because the company owns them.
Deployment should begin in shadow mode, where decisions are recorded but membership is not granted. Compare would-grant events with known workspace invitations and manually reviewed employees; do not turn that comparison into an invented universal threshold. Then canary by workspace, cap automatic grants per workspace over a rolling interval, and provide a kill switch that changes future decisions to pending without disabling signup.
I distrust a green dashboard here. The useful telemetry is an append-only decision event containing an opaque account identifier, workspace identifier, normalized domain or privacy-preserving derivative, claim version, outcome, reason code, and correlation ID. Do not log verification tokens or passwords. Metrics should separate accounts created, emails verified, admissions allowed, admissions held, and grants later revoked. Alert on changes in these relationships and on invariant violations; a page saying “signup latency is high” is secondary to one saying “membership grants exceed the workspace's established envelope.”
Recovery must be rehearsed. Responders need to suspend domain-based grants by claim version, preserve accounts for investigation, revoke active sessions where exposure is plausible, and notify the workspace through its established security channel. This is why the reason and policy version belong on the membership record. Reconstructing authorization from general request logs at 3 a.m. is not a control.
Where auto-enrollment should stop
This approach has real limitations. Do not use it for public mailbox domains, domains shared by unrelated organizations, or workspaces where membership alone exposes highly sensitive data. It is also a poor fit when employment changes cannot be obtained quickly enough to remove stale access. In those cases, verified invitations, administrator approval, directory provisioning, or a federated identity flow with managed lifecycle signals is the more defensible boundary. Auto-enrollment reduces administrator work at signup, but it accepts domain control as a coarse organizational signal and creates a revocation obligation; manual approval costs more time, yet it can evaluate a person's actual relationship to a property portfolio before any membership exists.
None of those mechanisms removes the need for abuse controls. They move the evidence and the party responsible for the decision.
For lower-risk property operations, verified-domain admission can be reasonable when baseline membership reveals little, sensitive roles require another approval, and revocation is fast. The decision rule is operational: choose auto-enrollment only when the team can explain the grant from durable records, detect abnormal grant behavior, and revoke the resulting access faster than the data exposure can spread.
Top comments (0)