DEV Community

CardFlow
CardFlow

Posted on

Designing a Privacy-Safe Gift Card Image Submission Pipeline

A gift card image is not an ordinary profile photo. It can contain a redeemable code, a PIN, a receipt, an email address, an order number, and location metadata from the camera. A single authorization bug can therefore expose both personal data and something that behaves like a bearer secret.

This article designs the upload path as a security boundary. The examples are implementation-neutral TypeScript so the controls can be mapped to your framework, image decoder, object store, and queue.

The goal is not “secure file upload” in the abstract. It is a narrower property:

Collect only the evidence needed for a decision, keep the original out of normal review paths, and make every retained copy private, attributable, and short-lived.

Start with staged disclosure

Do not begin by asking for the entire card and receipt. Most first-pass routing decisions need only structured facts:

  • brand and issuing country
  • currency and face value
  • physical card or e-code
  • proof type available
  • whether the redeemable area is still covered

Only request an image after those fields show that visual proof is necessary. For the first image, instruct the user to keep the code or PIN covered and exclude unrelated receipt lines. If a later step genuinely needs a live code, collect it through a separate, purpose-built secret field—not as another image in a support chat.

That separation changes the failure mode. A bug in the ordinary proof viewer should not automatically reveal a spendable credential.

The FTC explains why the distinction matters: someone who has the gift card number and PIN may be able to take the funds even without holding the physical card. Treat those values as secrets, not harmless text printed in a photo.

Threat-model the whole path

An upload control on the browser is useful feedback, but it is not a trust boundary. Model at least these failures:

Threat Example Required control
Secret exposure A full PIN appears in a proof image or log Staged disclosure, detection, restricted escalation
Cross-tenant access User A changes an object ID and sees User B's proof Server-side ownership check on every read
Malicious input receipt.jpg is HTML, a polyglot, or a decompression bomb Signature check, safe decode, byte and pixel limits
Metadata leakage A phone photo contains GPS or device data Decode and re-encode a review derivative without metadata
Public storage A guessed object URL works without authentication Private buckets and mediated access
Excess retention Rejected and abandoned uploads remain indefinitely State-based deletion jobs with measurable SLAs
Insider overreach Support can browse every original Least privilege, purpose-bound access, audit events

OWASP's file-upload guidance recommends defense in depth: allow only business-required types, do not trust the client-supplied MIME type, generate storage names, impose size limits, store outside the web root, and scan files when appropriate. Those are the baseline, not the complete privacy design.

Use a quarantine-to-review state machine

A useful state model is:

intent_created
  -> upload_quarantined
  -> validation_running
  -> review_ready | rejected
  -> decided
  -> purged
Enter fullscreen mode Exit fullscreen mode

Each transition should be server-controlled and idempotent. The browser never chooses review_ready, and an object-store callback never decides ownership.

The data flow can look like this:

browser
  -> authenticated upload intent
  -> short-lived upload capability
  -> private quarantine object
  -> validator queue
  -> decode + inspect + normalize
  -> private review derivative
  -> authorized reviewer
  -> decision + scheduled deletion
Enter fullscreen mode Exit fullscreen mode

Keep the quarantine and review stores logically separate. The original object is untrusted input. Normal reviewers should receive the normalized derivative, not a direct link to quarantine.

Make the upload intent the authorization root

Create a database record before issuing an upload capability:

type SubmissionIntent = {
  id: string;
  ownerId: string;
  tenantId: string;
  purpose: "gift_card_proof";
  status: "intent_created" | "upload_quarantined" | "validation_running" |
          "review_ready" | "rejected" | "decided" | "purged";
  quarantineKey: string | null;
  reviewKey: string | null;
  expiresAt: Date;
};
Enter fullscreen mode Exit fullscreen mode

The object key should be generated by the server and bound to that intent. A random key reduces collisions; it does not replace authorization.

When the application serves an image, resolve the intent first and enforce the relationship:

async function authorizeProofRead(actor: Actor, intentId: string) {
  const intent = await db.submissionIntent.findById(intentId);
  if (!intent || intent.status !== "review_ready") throw notFound();

  const ownsSubmission = actor.userId === intent.ownerId;
  const assignedReviewer = await reviewQueue.isAssigned(actor.userId, intent.id);

  if (!ownsSubmission && !assignedReviewer) throw notFound();
  return intent;
}
Enter fullscreen mode Exit fullscreen mode

Returning 404 for unauthorized object references avoids confirming that another user's submission exists. The important part is the database relationship check, not the response code.

Validate bytes, then decode, then normalize

Do not accept an image because its filename ends in .jpg or its request header says image/jpeg. A safer worker performs several independent checks:

  1. Enforce a small allowlist such as JPEG, PNG, and WebP.
  2. Limit compressed bytes before buffering the whole request.
  3. Detect the file signature from the bytes.
  4. Decode with a maintained image library in a constrained worker.
  5. Limit decoded width, height, total pixels, frames, and processing time.
  6. Re-encode to one controlled output format without carrying metadata forward.
  7. Scan or sandbox the file when your risk model and tooling support it.
  8. Write the derivative under a new server-generated key.

Implementation-neutral TypeScript makes the order explicit:

const POLICY = {
  maxBytes: 8 * 1024 * 1024,
  maxPixels: 24_000_000,
  allowed: new Set(["image/jpeg", "image/png", "image/webp"]),
};

async function validateAndNormalize(input: QuarantinedObject) {
  if (input.byteLength > POLICY.maxBytes) return reject("file_too_large");

  const signature = await fileInspector.detect(input.prefixBytes);
  if (!POLICY.allowed.has(signature.mime)) return reject("type_not_allowed");

  const probe = await imageDecoder.probe(input.stream, {
    maxPixels: POLICY.maxPixels,
    maxFrames: 1,
    timeoutMs: 5_000,
  });

  if (probe.width * probe.height > POLICY.maxPixels) {
    return reject("pixel_limit_exceeded");
  }

  const normalized = await imageDecoder.decodeAndEncode(input.stream, {
    output: "image/jpeg",
    autoOrient: true,
    stripMetadata: true,
    maxPixels: POLICY.maxPixels,
  });

  return accept(normalized);
}
Enter fullscreen mode Exit fullscreen mode

The constants are examples, not universal safe values. Choose them from the smallest image that still supports your review task and test the limits against your actual decoder.

Re-encoding is valuable because it creates a controlled derivative and normally drops EXIF when configured to do so. It is not a malware guarantee. Keep the decoder isolated, patched, resource-limited, and unable to reach unrelated internal services.

Detect accidental secrets without pretending OCR is perfect

OCR can help identify likely gift card codes, email addresses, phone numbers, and payment-card patterns. It should not silently rewrite evidence or make a final fraud decision.

A safer result is a review gate:

type ExposureFinding = {
  kind: "possible_gift_code" | "email" | "phone" | "payment_card";
  confidence: number;
  boundingBox: [number, number, number, number];
};

if (findings.some(f => f.kind === "possible_gift_code" && f.confidence > 0.92)) {
  await submissions.blockForUserRedaction(intent.id);
  await quarantine.scheduleDeletion(intent.quarantineKey, "PT1H");
}
Enter fullscreen mode Exit fullscreen mode

Tell the user what region appears exposed and ask for a new photo with the redeemable area physically covered. Do not send the detected text to analytics, error tracking, or a generative model. Do not store it merely because the OCR engine returned it.

Some workflows may need the unmodified original for a tightly scoped investigation. Treat that as an exception: require a reason, grant time-limited access, log the actor and intent, and delete the original when the escalation closes.

Keep object storage private

Recommended defaults:

  • block public access at the account and bucket level
  • encrypt quarantine and review objects at rest
  • use separate keys or access policies for the two stores
  • issue upload and download capabilities with short expirations
  • bind capabilities to one object key, method, size range, and content type where supported
  • never put a signed download URL in logs, support tickets, or analytics
  • set private responses to avoid shared caching
  • keep original filenames only if there is a documented need; otherwise discard them

A signed URL is temporary authorization. Anyone who receives it can usually use it until it expires, so keep its lifetime short and generate it only after the database authorization check.

Make retention a state transition, not a policy paragraph

“We delete uploads when no longer needed” is not testable. Put deletion deadlines in data and make the purge job observable.

An example policy—not legal advice and not a universal schedule—might be:

State Example deletion trigger
Intent created, no upload Intent expiry
Validation rejected Quarantine cleanup within hours
User abandons redaction retry Short retry-window expiry
Review accepted or rejected Case-specific retention deadline
Security escalation Explicit exception expiry

Store deleteAfter, retentionReason, and any exception owner. A daily job should delete both the database pointer and the object, then emit a content-free audit event. Alert when deletion falls behind its SLA.

NIST's Privacy Framework is useful here because it treats data processing and privacy risk as an enterprise risk-management problem. In practice, minimization should affect product flow, storage design, support tools, and deletion—not only the privacy notice.

Log events, not evidence

Good audit events answer who did what without copying the image or its secrets:

{
  "event": "proof_view_granted",
  "intent_id": "si_01J...",
  "actor_id": "usr_01J...",
  "role": "assigned_reviewer",
  "purpose": "manual_verification",
  "occurred_at": "2026-08-12T09:30:00Z"
}
Enter fullscreen mode Exit fullscreen mode

Avoid logging:

  • original filenames
  • OCR text
  • image URLs or signed query strings
  • receipt line items
  • user-supplied free text without filtering
  • full request bodies from upload endpoints

Also inspect third-party observability defaults. A carefully designed application can still leak secrets if an APM agent records headers, URLs, or request payloads.

Test the privacy properties

Unit tests for MIME types are not enough. Add adversarial and lifecycle tests:

  • valid extension with HTML or script bytes
  • double extension and misleading Content-Type
  • oversized compressed file and oversized decoded dimensions
  • animated input when only still images are allowed
  • corrupted image that crashes or stalls the decoder
  • EXIF GPS in the original, absent from the review derivative
  • user A requesting user B's intent and object key
  • expired upload and download capabilities
  • code-like text that triggers a redaction retry
  • secret-like content absent from logs and error events
  • rejected, abandoned, and completed objects deleted on schedule
  • queue retry does not create duplicate review objects

Include a recovery test too: if the worker writes the derivative and crashes before updating the database, the next run should reconcile or remove the orphan rather than retain it forever.

A practical review checklist

Before shipping, ask:

  1. Can the first decision be made without an image?
  2. Does the UI tell users to cover the redeemable area before capture?
  3. Is every upload private from the first byte?
  4. Are type, bytes, pixels, frames, and decode time constrained?
  5. Does normal review use a metadata-free derivative?
  6. Is authorization checked from the submission record on every read?
  7. Are signed URLs short-lived and excluded from logs?
  8. Can support access an original only through an audited exception?
  9. Does every state have a deletion trigger?
  10. Do tests prove cross-tenant denial and actual object deletion?

User-facing capture guidance still matters because the safest secret is the one never uploaded. For an example of that companion layer, see CardFlow's guide to uploading better gift card proof. The backend controls above assume the UI has already helped the user minimize the image.

References

A privacy-safe image pipeline is not a single scanner or bucket setting. It is the combination of staged collection, private quarantine, controlled transformation, relationship-based authorization, purpose-bound review, and verified deletion. When those controls are expressed as states and tests, privacy becomes an engineering property instead of a promise.

Top comments (0)