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 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 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; }; 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; } 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: - Enforce a small allowlist such as JPEG, PNG, and WebP. - Limit compressed bytes before buffering the whole request. - Detect the file signature from the bytes. - Decode with a maintained image library in a constrained worker. - Limit decoded width, height, total pixels, frames, and processing time. - Re-encode to one controlled output format without carrying metadata forward. - Scan or sandbox the file when your risk model and tooling support it. - 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); } 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"); } 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 tool
Comments
No comments yet. Start the discussion.