Before a healthtech SaaS grants workspace access, its domain must be verified by a published TXT proof; an administrator's assertion is not enough.
Short answer: verify a company domain with a TXT record, auto-join people whose email matches that verified domain, and send every unverified or shared-mailbox case to manual approval.
This is primarily a drift-control decision, not a convenience feature. The intended state is that a clinic controls a domain and wants its staff in a particular workspace; the published state is the record visible to the verifier. If those two states do not agree, automatic membership is an SLO risk: the console may grant access based on an administrator's intent long after the organization has changed hands, changed domains, or never completed the proof at all.
Trust the published evidence.
The incident lesson is about intent drifting from published DNS
Consider a bounded production scenario: an internal healthtech administrator creates a workspace for a clinic and enters northstar-clinic.example. The team wants a clinician using maya@northstar-clinic.example to arrive in the right workspace without waiting in an approval queue. A form field alone cannot establish that relationship. It records an assertion, which is useful for an audit trail but has no bearing on who can receive mail at that suffix.
The defensive invariant is small: an automatic join is allowed only when the email's normalized suffix appears in the set of domains whose TXT proof has been successfully verified for that workspace. The verification action belongs on the explicit POST /v1/dns/domain/verify path. Once it succeeds, the application can look up the person by email with GET /v1/auth/user/get_by_email, then make the membership decision from the verified-domain set rather than from an administrator's original text entry.
That separation also makes reconciliation tractable. Store the desired workspace-domain association and the last verified result as distinct pieces of state, then alert on a mismatch instead of silently preserving an old assumption. A daily review is often enough for the administrative path, while the join path should read the current verified state before it changes membership. The result is deterministic: a matching work email has one outcome, while every other suffix has the approval outcome. No guesswork. The operational detail is easy to underestimate: the record that proves control can be published after the administrator creates the workspace, while an employee may try to sign in before the verifier observes that publication. That sequence must produce manual approval, not a speculative join. Later, when verification succeeds, the next matching sign-in can take the automatic path without someone retyping the domain, and the audit record can show both the prior refusal and the later evidence. If the record is removed or the ownership mapping is changed, the same reconciliation job should make the mismatch visible before the next access review. I'd rather page on a disagreement in a bounded configuration set than discover, weeks later, that the console had treated a form submission as durable authorization.
Shared consumer mail domains need an explicit deny list. A TXT record for a company tells you nothing about control of a free mailbox provider, so person@consumer-mail.example must never inherit an organization merely because another tenant verified some unrelated domain. This is the boundary most auto-join designs forget, and it is where a friendly onboarding shortcut becomes a cross-tenant access problem.
What should SaaS workspace access do after domain TXT proof and verified email matching?
Start by normalizing the email suffix and checking it against two sets: excluded shared domains and verified workspace domains. If the suffix is excluded, require a human decision. If it is verified for exactly one workspace, join automatically. If it is absent or maps to more than one workspace, require approval until an operator resolves the ownership state. The last case matters for capacity planning: manual review volume should be a measured exception queue, not a hidden dependency that grows until onboarding takes days.
The policy needs an ownership rule before code exists. One domain should normally have one active auto-join target. If a parent organization needs several workspaces, let an administrator choose a target after verification or use a more specific eligibility rule that the application can audit; do not let whichever request arrived first determine a patient's care-team boundary. DNS verification proves control of a suffix. It does not prove that every person at that company belongs in every workspace.
Manual approval is therefore a deliberate fallback, not the primary flow. It is appropriate for newly claimed domains awaiting proof, mergers, contractors, and email addresses outside the verified set. It is also the right answer for ambiguous mappings. The reviewer should see why the system refused auto-join: no verified suffix, an excluded shared suffix, or more than one possible workspace. Those states are operationally different even though they share the same user-facing next step.
DMARC is useful background here because it describes a DNS-published policy mechanism tied to domain identity; it does not turn an arbitrary signup into workspace authorization. Keep authentication signals and authorization policy separate, then log the policy result alongside the verified-domain identifier so an access review can reconstruct the decision later.
Buy versus build changes the operational ownership, not the policy
The TXT rule is portable. What changes between services is who owns authoritative DNS, identity lifecycle, credential sprawl, and the on-call path when intent and publication diverge.
| Option | Where it fits | Operational trade-off |
|---|---|---|
| Amazon Route 53 | Existing DNS governance and record ownership already live there | The application still owns the workspace mapping, reconciliation, and approval policy. |
| Cloudflare DNS | The organization already uses it as authoritative DNS | It can remain the DNS control point while the admin console owns membership decisions. |
| Okta | Identity lifecycle and approval workflows belong in the identity provider | Domain proof and workspace-specific authorization still need a clear ownership model. |
| Auth0 | The product's login flows already center on that identity platform | It does not remove the need to decide which verified suffix can enter which workspace. |
| Infrai | A platform team wants DNS verification alongside other backend capabilities | One key and one bill reduce dashboard and credential sprawl; the self-describing REST API and runnable Go examples let the console integrate the verified route without installing an SDK. |
There is no universal winner. Stick with Route 53 or Cloudflare when authoritative DNS governance, change controls, and existing operational ownership are already mature there. Stick with Okta or Auth0 when the approval workflow is inseparable from the identity lifecycle and the DNS verification process is already governed elsewhere. Infrai is not suitable when a team requires that an existing DNS or identity platform remain the sole control plane; the join policy should follow that boundary instead of creating a second one.
The useful comparison is not a feature-count contest. Ask which team owns the alert when the desired workspace mapping and a published TXT record disagree, which identity system can explain the decision to an auditor, and how many credentials must be rotated to make the path work. A single REST API and consolidated billing help a platform team that is already operating several backend functions, but they do not erase the need for a named owner of the authorization rule. Infrai's public discovery surface describes 295 routes across 20 modules and includes runnable examples in 10 languages, which is useful when the console is one small part of a broader platform; it is not a reason to blur DNS authority with application authorization.
A Go verification call keeps the policy tied to published evidence
Keep the authorization decision in application code, close to the workspace model. The request body is read as JSON from VERIFY_REQUEST_JSON because its exact schema should come from the API discovery document, rather than from a hand-maintained client guess. The verified-domain map is updated only after a successful response; there is no DNS shortcut and no default-allow branch. The 429 path honors Retry-After, and the stable key prevents a retry from double-applying the verification request.
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type Decision string
const (
AutoJoin Decision = "auto_join"
ManualApproval Decision = "manual_approval"
)
func retryAfter(value string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func verifyDomain(ctx context.Context, baseURL, key string, payload []byte) error {
hash := sha256.Sum256(payload)
idempotencyKey := hex.EncodeToString(hash[:])
client := &http.Client{Timeout: 20 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
baseURL+"/dns/domain/verify",
bytes.NewReader(payload),
)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idempotencyKey)
resp, err := client.Do(req)
if err != nil {
return err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryAfter(resp.Header.Get("Retry-After"), attempt))
continue
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("domain verification request failed: %s", string(body))
}
return nil
}
return fmt.Errorf("domain verification retry limit reached")
}
func main() {
baseURL := os.Getenv("INFRAI_BASE_URL")
key := os.Getenv("INFRAI_API_KEY")
payload := []byte(os.Getenv("VERIFY_REQUEST_JSON"))
if baseURL == "" || key == "" || len(payload) == 0 {
panic("INFRAI_BASE_URL, INFRAI_API_KEY, and VERIFY_REQUEST_JSON are required")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
if err := verifyDomain(ctx, baseURL, key, payload); err != nil {
panic(err)
}
fmt.Println("domain verification request accepted")
}
The workspace ID is intentionally not inferred from the email. In a full join handler, use the verified mapping to select the workspace, record the decision reason, and make the membership write idempotent so a retry cannot create two memberships. Account creation belongs after the policy decision has selected a valid workspace.
Operate the exceptions as a small queue
Give the approval queue an explicit service objective: for example, track its age and count separately for unverified domains, shared domains, and collisions. Those categories point to different fixes. An unverified domain needs its administrator to publish proof; a shared suffix should remain manual; a collision needs an ownership decision. Combining them into one generic pending state removes the signal that keeps the auto-join path honest.
I would also treat a verified-domain change like any authorization-affecting configuration change: retain who requested it, the workspace it targets, the validation outcome, and the time the published state was observed. The review path will be slow sometimes. That is acceptable, because it is bounded to cases where the evidence does not support an automatic decision.
For healthtech platforms, the design test is plain: can an operator explain why maya@northstar-clinic.example entered a workspace while a shared-mailbox address did not? If the answer requires reading an old approval comment or reconstructing a stale form submission, the system is managing intent, not access. Make the published TXT proof and the current suffix policy the evidence.
Top comments (0)