DEV Community

LyraP22
LyraP22

Posted on

Verified Company Domains: Safe Auto-Join Decisions for E-commerce Workspaces

Register company-domain claims before using an email suffix to place a customer in an e-commerce workspace. The deciding constraint is ownership: buyer@northwind.example identifies a mail domain, but it does not prove that the person controls a store, should enter an existing tenant, or belongs beside every colleague.

Short answer: normalize the domain, fetch active claims by that exact key, and join only when one unambiguous claim explicitly permits automatic admission. Send zero matches through ordinary signup or an invitation flow. Quarantine multiple matches for review. Never guess.

This is a security boundary, not a convenience lookup.

How should verified company domains auto-join the right workspace?

A mailbox challenge answers whether the signup can receive mail at one address. The tenant lookup asks a different question: which organization, if any, has an active claim over that domain inside the application? Keep those proofs separate in the data model and logs. A successful email verification must not manufacture a domain claim.

For an e-commerce product, the failure is concrete. A merchant may let agencies, contractors, or regional teams use addresses under one company domain while keeping storefront data in separate workspaces. Merging them because their strings end alike can expose catalogs, orders, customer records, or deployment controls to the wrong group. The safe default is narrower: a verified claim makes a workspace eligible; its admission policy decides whether a signup may join.

DMARC does not fill this gap. RFC 7489 defines an email authentication, policy, and reporting mechanism built around DNS publication and identifier alignment. A DMARC record is not application authorization for tenant membership. Do not translate p=reject, organizational-domain alignment, or the existence of _dmarc into a workspace claim.

Model the claim before the lookup

Keep three records distinct: user email verification, workspace domain claim, and resulting membership. The extra schema work makes retries and revocation understandable. More important, it prevents a mail-security signal from silently becoming application authorization.

A claim needs the normalized domain, owning workspace ID, lifecycle state, admission policy, version, and timestamps. Put a uniqueness constraint on active claims for the normalized domain. The application check gives a useful error; the database constraint settles races.

Input state Decision
No active claim Continue normal signup
One active claim, auto-join enabled Add membership after email verification
One active claim, invite required Keep the user outside the workspace
More than one active claim Join nothing and raise an integrity alert
Claim revoked during signup Refuse the stale decision

Customer-owned and platform-owned DNS zones change the verification workflow, not the authorization rule. With a customer-owned zone, an administrator publishes the challenge through its DNS operator. With a platform-owned delegated zone, the platform manages records inside the delegated boundary. In both cases, bind proof to the exact claim, activate it only after verification, and make admission depend on current state. Zone custody is not tenant membership.

Make one deterministic decision

The hot path can stay small. Parse the address with a library matching the forms your identity system accepts; do not treat the split below as a complete email parser. Canonicalize the extracted domain at the boundary, store one representation, then perform an exact indexed lookup. Do not walk toward parent labels or use substring matching.

type DomainClaim = {
  domain: string;
  workspaceId: string;
  status: "pending" | "active" | "revoked";
  admission: "auto_join" | "invite_only";
  version: number;
};

type JoinDecision =
  | { kind: "join"; workspaceId: string; claimVersion: number }
  | { kind: "unclaimed" }
  | { kind: "invite_required"; workspaceId: string }
  | { kind: "conflict" };

interface ClaimStore {
  findActiveByDomain(domain: string): Promise<DomainClaim[]>;
}

function domainFromVerifiedEmail(email: string): string {
  const separator = email.lastIndexOf("@");
  if (separator <= 0 || separator === email.length - 1) {
    throw new Error("The identity service returned an invalid email");
  }
  return new URL(`https://${email.slice(separator + 1)}`).hostname;
}

export async function decideWorkspace(
  verifiedEmail: string,
  claims: ClaimStore,
): Promise<JoinDecision> {
  const domain = domainFromVerifiedEmail(verifiedEmail);
  const matches = await claims.findActiveByDomain(domain);

  if (matches.length === 0) return { kind: "unclaimed" };
  if (matches.length > 1) return { kind: "conflict" };

  const claim = matches[0];
  if (claim.admission === "invite_only") {
    return { kind: "invite_required", workspaceId: claim.workspaceId };
  }
  return { kind: "join", workspaceId: claim.workspaceId, claimVersion: claim.version };
}
Enter fullscreen mode Exit fullscreen mode

The returned version matters. Membership creation should confirm that the claim is active, points to the same workspace, and still has the observed version. Otherwise a revocation between lookup and write can admit a user under a stale decision. Make the operation idempotent as well: retrying one signup should return the existing membership.

There is a real trade-off here. Two authorization reads, the initial lookup and the version check at membership creation, add work compared with a single map lookup. Claim administration adds more: somebody must verify, activate, revoke, and sometimes reassign each domain. For a tiny shop where every employee receives a direct invitation, this design is not suitable; the claim machinery may cost more operational attention than it saves. It is also a poor fit when one company deliberately divides people with the same domain among many storefront workspaces and has no deterministic attribute for selecting among them. Use invitations or administrator approval in those cases. Auto-join earns its place only when a domain maps to one active workspace, the merchant explicitly wants domain-wide admission, and the product can revoke that decision promptly. That boundary is the point. The lookup is fast code wrapped around a slow governance choice.

Keep public mailbox providers and disposable-address policy out of this lookup unless the business maintains a separate policy for them. A hard-coded list ages quietly. Domain popularity also says nothing about an active tenant claim in your system.

The simple approach fails at boundaries

The tempting implementation is a map from email.split("@")[1] to a workspace ID. It looks shippable. It hides malformed input, inconsistent case handling, Unicode domain representation, duplicate claims, and revocation. Each assumption becomes an authorization branch nobody can observe.

Subdomains need an explicit choice. staff.shop.example is not the same exact key as shop.example. If a business needs inheritance, represent it as approved claim data and test it; do not strip labels until something matches. Exact matching is easy to explain during an access review. Implicit suffix matching is not.

Reassignment is another boundary. A domain can move between workspaces. Suspend automatic admissions while ownership changes, invalidate outstanding verification challenges, and require fresh proof before activating the new claim. Existing memberships should follow a documented offboarding policy rather than disappearing as a side effect of a DNS check.

Fail closed on ambiguity. Fast failure is cheaper than cleanup.

Ship the invariant, then measure it

Test the decision table before connecting it to account creation. Include uppercase input, malformed addresses, exact subdomain differences, a revoked claim, duplicate active rows, concurrent updates, and retry of an existing membership. The duplicate should be impossible under the database constraint, but its runtime branch turns corruption into an alert instead of arbitrary tenant selection.

For deployment, begin with decision-only logging and compare outcomes with the current invitation process. Logs can carry a correlation ID, normalized domain, claim ID or hashed equivalent, claim version, decision reason, and workspace ID where policy permits. Avoid logging the full email merely to debug routing. Separate counters for unclaimed, invite-required, conflict, stale-version, and joined outcomes say more than one broad success rate.

Watch latency at the claim lookup and conditional membership write separately. Cache only when measurements justify it; revocation makes invalidation part of the authorization design. A small indexed lookup is the simpler starting point. Keep AI components out of this deterministic identity decision: no model call should interpret a claim or choose a tenant.

Before copying this design, measure how many signups hit claimed domains, how often administrators select invite-only admission, the rate of stale decisions, lookup latency at high percentiles, and the operational cost of conflicts. Those numbers show whether auto-join removes meaningful friction or merely expands an access-control surface.

The final rule is intentionally boring: verified mailbox, active exact-domain claim, explicit auto-join policy, one match, and a claim that remains current at commit time. Anything else stays outside the workspace.

References

Top comments (0)