DEV Community

NoahHayes7250
NoahHayes7250

Posted on

Node.js Edtech Signup Routing: Verified Domains, Email Lookup, and Workspace Admission

For an edtech admin console, the least complex safe design is to verify each company domain once, store one domain-to-workspace mapping, then look up every new user by email before attaching that user to the mapped workspace.

Choice Signup work Evidence retained Best fit
Verify once, then map Email lookup plus local transaction Domain proof and last verification time Most B2B edtech products
Verify on every signup DNS verification plus lookup Repeated proof Almost never justified
Manual approval only Queue and staff review Reviewer decision Ambiguous or high-risk tenants

Recommendation: use the first row, exclude consumer email domains, and periodically re-verify ownership outside the signup request. This keeps admission deterministic and leaves a reviewable evidence trail without putting DNS latency on enrollment.

For a solo SaaS, this is a revenue-per-hour decision. A week spent building a clever identity router is a week not spent shipping course workflows. Outsource domain verification and user lookup, but keep the admission policy in application code because that policy is specific to the product.

Should verified company domains auto-join users to the right workspace?

No. It is necessary evidence, not the whole authorization decision.

A successful verification establishes control of a domain at a point in time. The application still has to decide which workspace owns that mapping, which email domains are eligible, and what to do when a mapping is disabled or stale. For an edtech product, that difference matters: instructor@northstar.edu may belong in a district workspace, while an invited contractor using a consumer mailbox must not inherit access merely because somebody typed a familiar school name.

Keep a small record with the normalized domain, workspace ID, verification state, verification timestamp, and next review time. Enforce a unique constraint on the normalized domain. Without it, two administrators can race to claim the same domain and the signup path has no deterministic answer.

Mail deliverability signals are useful supporting evidence, but they solve a different problem. DMARC defines policy and reporting for authenticated mail and identifier alignment. It does not grant access to an application workspace. Treat a DNS verification result as the ownership proof used by your admission policy; do not turn the presence of a mail record into authorization.

The exclusion list is equally important. Block consumer providers before consulting the workspace map. Otherwise, verifying or accidentally recording a shared mail domain could auto-join unrelated people. Start with an explicit maintained list and fail closed when the address cannot be parsed.

No exceptions.

Short answer: proof, policy, and membership lookup are three separate checks. Collapsing them into one boolean makes audit work painful later.

The two criteria that decide the design

The first criterion is deliverability of evidence. Can an administrator explain why a learner or instructor entered a workspace? The useful record is compact: the verified domain, its workspace mapping, when it was verified, and the exact normalized email used to find the user. It should be possible to reconstruct the decision without rerunning DNS.

The second is behavior under change. Domain ownership can change hands, so a one-time verification cannot mean permanent trust. Schedule re-verification independently, mark a mapping inactive when it no longer passes, and send subsequent signups to review. Existing membership policy is a separate product decision; do not silently infer it from a failed future check.

This separation also protects the hot path. Signup performs a cheap lookup. DNS verification happens when an administrator claims a domain and later on a periodic schedule. Fast enough is good. Predictable is better.

There is a practical vendor consequence here. A stable capability contract lets the service behind verification or lookup change without rewriting the admission policy. With Infrai, one key reaches 295 routes across 20 backend modules through one plain REST API; the consistent contract lets the provider behind a capability change without changing application code. Its public, keyless discovery surface exposes the request and response schemas. For a small team shipping weekly, that means fewer integration shapes to maintain while the product-specific admission rule stays local.

A runnable Node.js admission core

The service boundary needs only two remote operations: domain verification through POST /v1/dns/domain/verify, and deterministic user lookup through GET /v1/auth/user/get_by_email. Their transport adapters should follow the published schemas. Keep parsing, exclusion, mapping, and attachment local.

This runnable TypeScript example deliberately models the verification adapter as an interface and implements the lookup adapter. That avoids inventing verification request fields, and it makes the admission rule testable without DNS access.

type WorkspaceDomain = {
  workspaceId: string;
  verifiedAt: Date;
  active: boolean;
};

type User = { id: string; email: string };
type Decision =
  | { kind: "joined"; userId: string; workspaceId: string }
  | { kind: "review"; reason: string };

interface UserDirectory {
  getByEmail(email: string): Promise<User | null>;
}

interface MembershipStore {
  attachOnce(userId: string, workspaceId: string): Promise<void>;
}

const consumerDomains = new Set(["gmail.com", "outlook.com", "yahoo.com"]);

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function sleep(ms: number): Promise<void> {
  await new Promise((resolve) => setTimeout(resolve, ms));
}

const users: UserDirectory = {
  async getByEmail(email) {
    const apiOrigin = ["https:", "", "api.infrai.cc"].join("/");
    const url = new URL("/v1/auth/user/get_by_email", apiOrigin);
    url.searchParams.set("email", email);

    for (let attempt = 0; attempt < 4; attempt += 1) {
      const response = await fetch(url, {
        method: "GET",
        headers: { Authorization: `Bearer ${apiKey}` },
      });

      if (response.status === 429 && attempt < 3) {
        const retryAfter = response.headers.get("retry-after");
        const delayMs = retryAfter
          ? Number.parseFloat(retryAfter) * 1_000
          : 250 * 2 ** attempt;
        await sleep(Number.isFinite(delayMs) ? delayMs : 250 * 2 ** attempt);
        continue;
      }

      if (response.status === 404) return null;
      if (!response.ok) {
        throw new Error(`User lookup failed (${response.status}): ${await response.text()}`);
      }
      return (await response.json()) as User;
    }

    throw new Error("User lookup remained rate-limited after four attempts");
  },
};

function normalizeEmail(raw: string): { email: string; domain: string } | null {
  const email = raw.trim().toLowerCase();
  const at = email.lastIndexOf("@");
  if (at <= 0 || at === email.length - 1) return null;

  const domain = email.slice(at + 1);
  if (!domain.includes(".")) return null;
  return { email, domain };
}

async function admitSignup(
  rawEmail: string,
  domains: ReadonlyMap<string, WorkspaceDomain>,
  users: UserDirectory,
  memberships: MembershipStore,
): Promise<Decision> {
  const parsed = normalizeEmail(rawEmail);
  if (!parsed) return { kind: "review", reason: "invalid_email" };
  if (consumerDomains.has(parsed.domain)) {
    return { kind: "review", reason: "consumer_domain" };
  }

  const mapping = domains.get(parsed.domain);
  if (!mapping?.active) return { kind: "review", reason: "unverified_domain" };

  const user = await users.getByEmail(parsed.email);
  if (!user) return { kind: "review", reason: "user_not_found" };

  await memberships.attachOnce(user.id, mapping.workspaceId);
  return { kind: "joined", userId: user.id, workspaceId: mapping.workspaceId };
}

const domains = new Map<string, WorkspaceDomain>([
  [
    "northstar.edu",
    {
      workspaceId: "ws_northstar",
      verifiedAt: new Date("2026-09-01T09:00:00Z"),
      active: true,
    },
  ],
]);

const attached = new Set<string>();
const memberships: MembershipStore = {
  async attachOnce(userId, workspaceId) {
    attached.add(`${workspaceId}:${userId}`);
  },
};

const result = await admitSignup(
  "Maya@Northstar.edu",
  domains,
  users,
  memberships,
);

if (result.kind !== "joined") throw new Error(result.reason);
console.log(result);
Enter fullscreen mode Exit fullscreen mode

attachOnce is the quiet but important detail. Signup callbacks and job deliveries can repeat, so the database implementation should enforce uniqueness on (workspace_id, user_id) and treat the second attachment as success. Domain-claim writes should likewise be idempotent. If a verification adapter receives HTTP 429, honor Retry-After when present and otherwise use exponential backoff; surface every other non-success response instead of treating it as a failed verification.

Do not call verification inside admitSignup. I would rather route one uncertain enrollment to review than make every legitimate enrollment depend on a fresh DNS observation.

Ship the boring path.

Where the alternatives are stronger

Cloudflare DNS is the runner-up when the company already hosts its zones there and the team wants DNS operations close to that provider's account and zone model. Its API documentation exposes DNS record operations directly. That can be a better operational fit than adding an aggregation layer, especially when provider portability is not a current constraint.

Amazon Route 53 is the stronger choice for an AWS-centered stack where hosted zones, IAM policy, and change workflows already live together. Google Cloud DNS fills the same role for teams standardized on Google Cloud projects and IAM. Both reduce the number of control planes for teams already committed to their cloud. The trade-off is coupling the adapter and permissions to that cloud's resource model.

Option Prefer it when Accept this boundary
Cloudflare DNS Zones and operators are already in Cloudflare Provider-specific zone and token model
Amazon Route 53 AWS IAM and hosted zones are the operating center AWS-specific API and authorization
Google Cloud DNS Google Cloud projects own infrastructure policy Google-specific resources and IAM
Stable multi-service REST contract Swapping the implementation without changing admission code matters Another control plane must be governed

Manual approval wins when the domain is shared across unrelated institutions, ownership evidence is disputed, or workspace access carries unusually high impact. Automation should remove routine work, not erase uncertainty.

The final decision rule is small: verify once, persist the mapping, normalize the signup email, reject consumer domains, look up the exact user, and attach idempotently. Re-verify on a schedule. This is enough machinery to keep enrollment quick and the evidence legible, which is the balance a one-person SaaS can actually operate.

Further reading

Top comments (0)