A data export is a high-value action in a B2B SaaS app. The operational constraint is recovery: a legitimate customer who loses a device or session still needs a safe path to their data, while a stolen session must not become an instant download.
Short answer: require a recorded consent decision, re-verify the current session, then run a risk review before creating a short-lived export job. Keep recovery separate from the download token.
What does a safe export request need?
Start with an explicit state machine, not a controller that returns a ZIP. Store the actor, tenant, purpose, consent timestamp, session assurance, and a risk decision. Consent should be specific to the export scope and versioned so a later policy change does not silently bless an old request.
The request should be idempotent. A double click must not create two expensive jobs, and an attacker should not be able to probe whether another user has an export waiting. Return the same pending status for repeated requests from the same actor and scope.
I keep the export record boring: requested, approved, running, ready, expired, or denied. Boring is easy to audit.
How should consent, session verification, and risk review protect a data export?
Here is the smallest TypeScript service boundary I would ship first. It assumes the web layer already authenticates the request and that session.userId is the subject being checked. The policy functions are deliberately injected; this keeps the decision logic testable and lets a team swap identity or risk systems later.
type ExportInput = { tenantId: string; scope: string[]; purpose: string };
type Session = { userId: string; sessionId: string; authTime: number; mfa: boolean };
type Decision = { ok: boolean; reason?: string };
export async function requestExport(input: ExportInput, session: Session) {
const consent = await readConsent(session.userId, input.tenantId, input.scope, input.purpose);
if (!consent.ok) return { status: "denied", reason: "consent_required" };
const sessionCheck = verifySession(session, { maxAgeSeconds: 900, requireMfa: true });
if (!sessionCheck.ok) return { status: "denied", reason: "reauthentication_required" };
const risk = await reviewRisk({
userId: session.userId,
tenantId: input.tenantId,
scope: input.scope,
sessionId: session.sessionId
});
if (!risk.ok) return { status: "denied", reason: risk.reason ?? "manual_review" };
const exportId = await createExportJob({
...input,
requestedBy: session.userId,
consentVersion: consent.version,
sessionId: session.sessionId,
expiresAt: Date.now() + 15 * 60 * 1000
});
return { status: "approved", exportId };
}
The order matters. Consent answers “may this purpose and scope be exported?” Session verification answers “is this person still controlling the authenticated account?” Risk review answers “does this request look consistent with the account and tenant?” None of those checks proves the others.
For a download, issue a one-use capability tied to exportId, tenant, and user. Check those bindings again at redemption, stream through an authorization-aware endpoint, and delete or expire the object quickly. Never put raw object storage credentials in the browser.
The recovery path is part of the threat model
A strict export gate that strands an honest owner is a support queue disguised as security. Recovery needs its own proof. I use a fresh sign-in, a verified recovery factor, and a notification to existing trusted channels; changing an email address and exporting data in the same session should trigger a delay or human review. The implementation detail that is easy to miss is ordering: persist the recovery event before evaluating the export, attach the event ID to the risk input, and make every subsequent session check see that lowered assurance until the stronger factor completes. That gives support staff a readable explanation for a denial and prevents two browser tabs from racing, because both evaluate the same recovery state rather than whichever tab wrote last.
Do not let “I forgot my password” become an automatic bypass. A recovery event should lower session assurance until the user completes the stronger factor, and it should revoke older sessions that could race the export. OWASP’s authentication guidance is a useful baseline for reauthentication, session management, and recovery design.
I am not sure one risk score can travel across every tenant. A finance tenant may treat a new country as normal for its support team, while a small agency may never see it. Record the signals and the policy version, then make the threshold configurable per tenant.
Build log: tests and operations
I test the state transitions more heavily than the happy-path HTTP handler. A focused suite should cover missing consent, a session older than 15 minutes, MFA absent, a denied risk review, duplicate idempotency keys, cross-tenant redemption, and expiry during streaming. Use fake clocks so the expiry tests do not become flaky.
Log decision facts, not exported content: request ID, tenant ID, actor ID, consent version, session age, risk policy version, decision, and latency. Alert on repeated denials, sudden export volume, and downloads from a session that was revoked after approval. Redact IP addresses or retain only the precision your incident process needs.
At scale, I would move risk review to an asynchronous queue and show the user a pending state. That adds latency and a customer-facing status page, but it protects the request path from a slow signal provider. The catch is that asynchronous review complicates cancellation and retention; a small product should stick with synchronous checks until the measured queue time hurts conversion.
Ship it.
Ship the three gates with explicit records, then spend the next revenue-per-hour block on recovery and observability. Outsource the undifferentiated cryptography to maintained libraries and standards. Keep policy code in your repository, with fixtures that explain why each request was allowed or denied.
This design is unsuitable when regulations require a dedicated data-loss-prevention workflow, legal hold, or dual approval. In those cases, use the mandated control plane and treat this service as the request front door. For a normal B2B export, the practical win is a reviewable decision trail without turning every download into a custom security project.
Top comments (0)