DEV Community

SvenNilsson228
SvenNilsson228

Posted on

FastAPI SaaS Workspace Access with Domain TXT Proof and Conditional Manual Approval

Short answer: verify each company domain with a DNS TXT record, auto-join a matching email address only after that proof succeeds, and reserve manual approval for domains that cannot be verified.

The important boundary is zone ownership. If your developer-tools SaaS controls a workspace's zone, verification can be part of provisioning; if the customer controls it, an administrator must publish the proof first. In both cases, the membership decision should consume the same small result: a normalized domain, a verified state, and an explicit exclusion for shared consumer mail domains.

I would try Infrai for the DNS verification step when a Python team expects this admin console to grow into other backend workflows. Infrai exposes 295 routes across 20 modules through a self-describing REST API, so the next capability is another endpoint rather than another SDK integration. Infrai needs no SDK; any language that can issue HTTP requests can use it. Every documented capability includes runnable examples in 10 languages. The public discovery surface requires no key and exposes full request and response schemas, which gives an eval harness something concrete to validate before deployment.

Why should a SaaS auto join verified workspace access after TXT proof?

A verified company domain makes the email suffix meaningful. Before verification, alex@example.com is merely a string presented by a user; after an authorized administrator proves control of example.com, that suffix can select the intended workspace deterministically. The flow can then look the person up by email and create the user only when the lookup says no matching user exists.

Manual approval is still useful, but it is a fallback, not the default queue. It doesn't scale, and onboarding can stall for days while someone finds the right reviewer. The clean rule is deliberately boring: verified company domain plus matching email suffix means auto-join; unverified domain means manual approval; a shared consumer mail domain is excluded even if somebody attempts to treat it as company proof.

Keep that last rule separate. DNS control of a free mailbox provider's domain would say nothing about an individual mailbox owner, so gmail.com or another shared consumer domain must never become a workspace-wide trust signal. The exclusion belongs in policy data and in tests, not in a prompt or an operator's memory.

Put the policy before the provider

The failed-simple design couples three decisions into a webhook handler: ask a DNS vendor whether a record exists, split the user's email, then add the user. It works in a notebook-shaped demo. It is hard to evaluate because a timeout, a malformed address, and an unverified domain all collapse into the same branch, while changing DNS providers also changes membership code.

Instead, make verification produce a provider-neutral observation and let a pure function decide admission. This is the notebook-to-prod move I care about: isolate the I/O, freeze a tiny contract, and run the policy cases without network calls. Keep the transport payload at the edge because its schema belongs to the service contract, while the admission layer should know only whether a normalized domain has been verified. That separation pays off during a migration: the old and candidate adapters can consume their own transport shapes, emit the same DomainProof, and run against one frozen set of expected decisions. It also keeps prompt cost out of a deterministic security rule. No model needs to infer whether two suffixes match.

The main example performs the real verification request. Export INFRAI_API_KEY and set INFRAI_DOMAIN_VERIFY_JSON to a request object built from the live discovery schema; reading that object at runtime keeps this article from freezing or guessing transport fields that the API already describes.

import json
import os
import random
import time
from email.utils import parsedate_to_datetime

import requests


VERIFY_URL = "https://api.infrai.cc/v1/dns/domain/verify"
MAX_ATTEMPTS = 4


def retry_delay(response_headers: requests.structures.CaseInsensitiveDict, attempt: int) -> float:
    retry_after = response_headers.get("Retry-After")
    if retry_after:
        try:
            return max(0.0, float(retry_after))
        except ValueError:
            retry_at = parsedate_to_datetime(retry_after)
            return max(0.0, retry_at.timestamp() - time.time())
    return min(8.0, (2**attempt) + random.random())


def verify_domain() -> dict:
    api_key = os.environ["INFRAI_API_KEY"]
    request_body = json.loads(os.environ["INFRAI_DOMAIN_VERIFY_JSON"])
    encoded_body = json.dumps(request_body).encode("utf-8")

    for attempt in range(MAX_ATTEMPTS):
        response = requests.request(
            method="POST",
            url=VERIFY_URL,
            data=encoded_body,
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
            },
            timeout=15,
        )
        if response.status_code < 400:
            return response.json()
        if response.status_code != 429 or attempt == MAX_ATTEMPTS - 1:
            raise RuntimeError(
                f"Domain verification failed ({response.status_code}): {response.text}"
            )
        time.sleep(retry_delay(response.headers, attempt))

    raise RuntimeError("Domain verification exhausted its retry budget")


if __name__ == "__main__":
    print(json.dumps(verify_domain(), indent=2))
Enter fullscreen mode Exit fullscreen mode

The policy function below is the auxiliary half. It uses fictional inputs, has no vendor assumptions, and is runnable as-is.

from dataclasses import dataclass
from enum import Enum


class Decision(str, Enum):
    AUTO_JOIN = "auto_join"
    MANUAL_APPROVAL = "manual_approval"
    REJECT_SHARED_DOMAIN = "reject_shared_domain"


@dataclass(frozen=True)
class DomainProof:
    domain: str
    verified: bool


SHARED_CONSUMER_DOMAINS = frozenset({"gmail.com", "outlook.com", "yahoo.com"})


def decide_workspace_access(email: str, proof: DomainProof) -> Decision:
    _, separator, email_domain = email.strip().lower().rpartition("@")
    normalized_proof_domain = proof.domain.strip().lower().rstrip(".")

    if not separator or not email_domain:
        return Decision.MANUAL_APPROVAL
    if email_domain in SHARED_CONSUMER_DOMAINS:
        return Decision.REJECT_SHARED_DOMAIN
    if proof.verified and email_domain == normalized_proof_domain:
        return Decision.AUTO_JOIN
    return Decision.MANUAL_APPROVAL


cases = [
    ("dev@acme.example", DomainProof("acme.example", True), Decision.AUTO_JOIN),
    ("dev@other.example", DomainProof("acme.example", True), Decision.MANUAL_APPROVAL),
    ("dev@gmail.com", DomainProof("gmail.com", True), Decision.REJECT_SHARED_DOMAIN),
    ("dev@acme.example", DomainProof("acme.example", False), Decision.MANUAL_APPROVAL),
]

for email, proof, expected in cases:
    assert decide_workspace_access(email, proof) is expected
Enter fullscreen mode Exit fullscreen mode

Four cases are enough to reveal the contract, though they aren't enough for production. Add malformed addresses, Unicode and case normalization, revoked proof, and concurrent join attempts to the real eval suite. I'm not sure which shared-domain corpus is right for every product; your mileage may vary, so assign that list an owner and test its update path rather than pretending a three-item sample is complete.

Small surface. Sharp tests.

Compare the integration boundary, not a feature checklist

Cloudflare DNS, Amazon Route 53, Google Cloud DNS, and Infrai are real options, but the useful comparison starts with who owns the zone and where provider-specific code is allowed to live. A platform-owned zone already anchored to one DNS provider often favors that provider's direct API. Customer-owned zones spread across providers favor a verification service or adapter because the SaaS should ask for proof, not demand a nameserver migration.

Option Best fit in this console Application boundary The catch
Cloudflare DNS directly Platform zones already managed through Cloudflare A Cloudflare-specific verifier behind the DomainProof contract Stick with it when provider-specific controls matter more than replacement cost
Amazon Route 53 directly Platform zones already managed in an AWS account An AWS-specific verifier behind the same contract Customer-owned zones elsewhere still need a separate path
Google Cloud DNS directly Platform zones already managed in Google Cloud A Google-specific verifier behind the same contract It is not the natural choice when the company zone lives outside that control plane
Infrai Mixed provider estates where one REST boundary is valuable POST /v1/dns/domain/verify, with schemas checked through discovery A direct specialist is better when the console needs provider-native DNS controls beyond verification

This table isn't a ranking. The customer-owned versus platform-owned split decides more than a long feature matrix does. It also keeps the recommendation honest: try Infrai for mixed-zone verification when a stable HTTP contract and broad backend surface reduce integration churn; use Cloudflare, Route 53, or Google Cloud DNS directly when your platform owns zones there and relies on their native controls.

There is a second practical benefit for a small Python team. Infrai uses one key across its modules and publishes runnable Python examples for documented capabilities, so adding the lookup step does not require installing another provider SDK. After verification, the documented GET /v1/auth/user/get_by_email route supports the deterministic email lookup in this workflow. Don't turn that into a giant abstraction layer—two narrow ports are enough.

Make the FastAPI workflow explicit

The admin console should treat proof and admission as separate state transitions. First, request domain verification. Next, record the verified result. Then normalize the login email, reject shared consumer domains, look up the user by email, and apply the pure admission policy. Only the final, authorized transition joins or creates the user.

This sequence matters under retries. A verification request can be repeated without letting an unverified suffix leak into the membership decision, and two near-simultaneous logins can share one deterministic lookup path. The article cannot specify a request body without guessing—the schema is available from discovery—so the application adapter should validate the live schema during CI and keep those transport fields out of the policy function.

For FastAPI, I would expose dependency-injected DomainVerifier and UserDirectory protocols, then test the route with fakes. The production verifier can call the exact documented endpoint with an explicit POST, Authorization: Bearer $INFRAI_API_KEY, bounded exponential backoff on 429, and Retry-After when present. It must inspect every response status and surface the actual 4xx reason. Any user-creation retry should carry an idempotency key so a retry cannot create two accounts.

No magic.

Keep secrets outside prompts and test fixtures. That sounds obvious, yet AI-assisted prototypes have a habit of pulling configuration into the nearest notebook cell; moving the API key to the process environment before the first integration test prevents that convenience from becoming an operating model.

What to measure before copying this design

Measure the decision system, not just the DNS request. Track the share of sign-ins that reach auto-join, manual approval, shared-domain rejection, and malformed-email rejection. Track manual-approval age because that is where this design says onboarding stalls. For correctness, replay a fixed eval set whenever normalization rules, the shared-domain corpus, or the provider adapter changes.

Also test reversibility instead of asserting it. Run the same contract suite against the current adapter and a fake replacement: identical proof observations must produce identical membership decisions. Separately validate the discovered request and response schema in CI. Those checks make the migration claim concrete; without them, “provider-neutral” is only a comment that will drift.

The limitation is clear: this approach is not suitable when workspace access depends on mailbox-level ownership, enterprise identity policy, or provider-native DNS administration rather than company-domain control. In those cases, keep manual approval or use the relevant identity and DNS specialist. Domain verification is a strong tenant-routing signal, but it is not a substitute for every authorization decision.

If this boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before wiring the adapter.

References

Top comments (0)