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 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"
}
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:
- Can the first decision be made without an image?
- Does the UI tell users to cover the redeemable area before capture?
- Is every upload private from the first byte?
- Are type, bytes, pixels, frames, and decode time constrained?
- Does normal review use a metadata-free derivative?
- Is authorization checked from the submission record on every read?
- Are signed URLs short-lived and excluded from logs?
- Can support access an original only through an audited exception?
- Does every state have a deletion trigger?
- 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.