Best Practices for Securing Retail Shelf Images in AWS

This guide sits within the Retail Data Ingestion Pipelines for Store Photos component and solves one specific operational task: hardening the AWS path that shelf photographs travel from an associate’s phone or an automated imaging rig into Amazon S3 and on to a vision pipeline. Each frame captures live pricing, promotional execution, proprietary merchandising layouts, and occasionally identifiable faces or licence plates, so a single misconfigured bucket policy or an over-broad IAM role can leak competitive intelligence and trigger a privacy incident. The steps below lock the ingestion boundary, the storage layer, the compute layer, and the audit trail so a planogram compliance program can scale to thousands of stores without widening its attack surface.

Defense-in-depth path for a shelf image from store device to encrypted S3 and private inference A shelf capture leaves a store device or imaging rig and calls a backend that mints a pre-signed upload URL with a 15-minute lifetime. The upload then crosses into a VPC trust boundary — private subnets with no public ingress — where it passes through a scoped S3 and KMS VPC endpoint, lands in an SSE-KMS encrypted S3 bucket that has public access blocked and Object Lock enabled, and is read by Fargate inference workers running in a private subnet under least-privilege IAM. Inside the same boundary, S3 Data Events feed a CloudTrail, Macie, and GuardDuty audit and detection backstop. Each layer adds a control: scoped credentials, network isolation, encryption, least privilege, and continuous audit. Defense in depth — every hop a shelf image takes is wrapped by its own control VPC trust boundary — private subnets, no public ingress 1 2 3 4 5 Store device phone / imaging rig Pre-signed URL backend · 15-min TTL VPC endpoint S3 + KMS, scoped policy S3 bucket SSE-KMS · Object Lock public access blocked Fargate inference private subnet · least-priv IAM PUT upload store read CloudTrail Data Events · Macie PII scan · GuardDuty every GetObject / PutObject logged · faces & plates flagged before audit audit & detection backstop Data Events

Prerequisites Jump to heading

Before applying this page, confirm the following are in place:

  • An AWS account with an S3 ingestion bucket reserved for raw shelf captures (this guide assumes retail-shelf-analytics-prod).
  • A customer-managed KMS key (CMK) created for image encryption, referenced here as alias/shelf-analytics-cmk.
  • A VPC with at least one private subnet that hosts the inference workers, plus permission to create Gateway and Interface VPC endpoints.
  • boto3 and the AWS CLI v2 installed, authenticated with a deploy role that can edit bucket policy, KMS key policy, and IAM.
  • A naming convention for object keys. This guide writes captures to raw/{store_id}/{date}/{camera_id}.jpg so that key prefixes map cleanly to store and capture metadata. The same key shape feeds the reconciliation logic in Integrating Legacy POS Data with Modern Vision APIs.

Step-by-Step Implementation Jump to heading

Step 1 — Seal the bucket against public access Jump to heading

Enable all four BlockPublicAccess toggles at both the account and bucket level, disable ACLs, and enforce BucketOwnerEnforced so legacy cross-account grants cannot reintroduce exposure. Add S3 Object Lock in compliance mode with a fixed retention window of 3090 days so captures cannot be deleted mid-audit.

aws s3api put-public-access-block \
  --bucket retail-shelf-analytics-prod \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

aws s3api put-bucket-ownership-controls \
  --bucket retail-shelf-analytics-prod \
  --ownership-controls 'Rules=[{ObjectOwnership=BucketOwnerEnforced}]'

Step 2 — Enforce SSE-KMS with a customer-managed key Jump to heading

Set a bucket policy that rejects any PutObject whose x-amz-server-side-encryption is not aws:kms, and bind decryption to your CMK. A customer-managed key gives you per-call CloudTrail records, annual rotation, and a key policy you can scope to specific principals and VPC endpoints.

{
  "Sid": "DenyUnEncryptedUploads",
  "Effect": "Deny",
  "Principal": "*",
  "Action": "s3:PutObject",
  "Resource": "arn:aws:s3:::retail-shelf-analytics-prod/raw/*",
  "Condition": {
    "StringNotEquals": { "s3:x-amz-server-side-encryption": "aws:kms" }
  }
}

Step 3 — Issue tightly scoped pre-signed upload URLs Jump to heading

Never let store devices hold long-lived S3 credentials or call PutObject against a public bucket. Route every upload through a backend that mints a pre-signed URL bound to an exact object key, a single HTTP method, a image/jpeg content type, and a maximum lifetime of 15 minutes. The handler below also pins the KMS key into the signed parameters so the upload satisfies the Step 2 policy.

import logging
from datetime import datetime, timezone
from typing import Optional, Dict

import boto3
from botocore.exceptions import ClientError

logger = logging.getLogger(__name__)


class SecureShelfImageHandler:
    """Mints scoped upload URLs and verifies KMS-encrypted retrievals."""

    def __init__(self, region: str = "us-east-1") -> None:
        self.s3 = boto3.client("s3", region_name=region)
        self.kms = boto3.client("kms", region_name=region)
        self.bucket = "retail-shelf-analytics-prod"
        self.kms_key_id = "alias/shelf-analytics-cmk"

    def generate_upload_url(
        self,
        store_id: str,
        camera_id: str,
        ttl_minutes: int = 10,
    ) -> str:
        """Return a single-use, KMS-bound pre-signed PUT URL."""
        capture_date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        object_key = f"raw/{store_id}/{capture_date}/{camera_id}.jpg"
        try:
            url = self.s3.generate_presigned_url(
                "put_object",
                Params={
                    "Bucket": self.bucket,
                    "Key": object_key,
                    "ContentType": "image/jpeg",
                    "ServerSideEncryption": "aws:kms",
                    "SSEKMSKeyId": self.kms_key_id,
                },
                ExpiresIn=ttl_minutes * 60,
                HttpMethod="PUT",
            )
            logger.info("Issued upload URL for key=%s ttl=%dm", object_key, ttl_minutes)
            return url
        except ClientError as exc:
            logger.error("Pre-signed URL generation failed: %s", exc)
            raise

    def retrieve_image(
        self,
        object_key: str,
        encryption_context: Optional[Dict[str, str]] = None,
    ) -> bytes:
        """Download a capture, asserting the expected KMS encryption context."""
        try:
            response = self.s3.get_object(Bucket=self.bucket, Key=object_key)
            if encryption_context and response.get("SSEKMSKeyId") is None:
                raise ValueError("Object is not KMS-encrypted; refusing to serve")
            logger.info("Retrieved encrypted capture: %s", object_key)
            return response["Body"].read()
        except ClientError as exc:
            code = exc.response["Error"]["Code"]
            if code == "NoSuchKey":
                logger.warning("Capture missing: %s", object_key)
            elif code == "AccessDenied":
                logger.error("IAM denied retrieval of %s", object_key)
            raise

Step 4 — Keep traffic on the AWS backbone with VPC endpoints Jump to heading

Route all device-to-storage and worker-to-storage traffic through a Gateway VPC Endpoint for S3 (and Interface endpoints for KMS). Attach an endpoint policy that allows only s3:PutObject and s3:GetObject and explicitly denies s3:DeleteObject and s3:ListBucket, which removes the reconnaissance and bulk-delete primitives an attacker would reach for first.

{
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": "*",
      "Action": ["s3:PutObject", "s3:GetObject"],
      "Resource": "arn:aws:s3:::retail-shelf-analytics-prod/raw/*"
    },
    {
      "Effect": "Deny",
      "Principal": "*",
      "Action": ["s3:DeleteObject", "s3:ListBucket"],
      "Resource": "arn:aws:s3:::retail-shelf-analytics-prod*"
    }
  ]
}

Step 5 — Run inference in a private subnet under least-privilege IAM Jump to heading

Deploy the vision workers on ECS Fargate or Lambda inside the private subnet with zero direct internet egress; downstream calls exit through the VPC endpoints or a NAT gateway. Scan container images with ECR vulnerability scanning before they ship. Scope the task role so that KMS decryption is gated on an encryption context or aws:SourceVpce, and pull third-party vision API keys from Secrets Manager at runtime rather than baking them into the image. These workers are the same ones that later feed the broader Security Boundaries for Retail Image Data controls.

Step 6 — Scan for PII and turn on the audit trail Jump to heading

Enable S3 Data Events in CloudTrail so every GetObject, PutObject, and DeleteObject is logged, and point Amazon Macie at the raw/ prefix to flag unblurred faces or licence plates before they reach a vendor audit. Wire Macie findings into Security Hub and enable GuardDuty to catch compromised credentials and S3 reconnaissance.

aws macie2 create-classification-job \
  --job-type ONE_TIME \
  --name shelf-image-pii-scan \
  --s3-job-definition '{"bucketDefinitions":[{"accountId":"111122223333","buckets":["retail-shelf-analytics-prod"]}]}'

Verification & Testing Jump to heading

Confirm each control before declaring the pipeline production-ready:

  1. Public-access lockdown. Run aws s3api get-public-access-block --bucket retail-shelf-analytics-prod and assert all four flags are true. An anonymous curl against any object URL must return 403.
  2. Encryption enforcement. Attempt an unencrypted upload; the bucket policy from Step 2 must reject it with AccessDenied. A compliant PutObject should return a response whose ServerSideEncryption is aws:kms.
  3. URL expiry. Generate a URL with ttl_minutes=1, wait past the window, and confirm the PUT returns AccessDenied / Request has expired.
  4. Endpoint scoping. From a worker, aws s3api delete-object must fail and aws s3 ls must return empty or denied, proving the endpoint policy holds.
  5. Audit visibility. Trigger a download and verify a matching CloudTrail Data Event lands in the log group within 5 minutes; set a CloudWatch metric filter alarm on more than 50 AccessDenied responses in a 5-minute window.

Troubleshooting Jump to heading

Symptom Root cause Remediation
Uploads fail with AccessDenied despite valid credentials Device omits the x-amz-server-side-encryption: aws:kms header the Step 2 policy requires Pin ServerSideEncryption and SSEKMSKeyId into the pre-signed params (Step 3); the signed URL then carries the header
KMS.AccessDeniedException during retrieval Task role lacks kms:Decrypt for the CMK, or the encryption context does not match the key-policy condition Grant kms:Decrypt scoped by aws:SourceVpce; ensure the upload and download use the same encryption context
Pre-signed URLs work from anywhere, not just stores No geographic or endpoint constraint on the public ingestion endpoint Front the endpoint with AWS WAF rate-based and geo-match rules; prefer VPC-endpoint-only access for fixed-rig stores
Macie flags faces in parking-lot-facing frames Edge devices ship unredacted images Apply region-of-interest blurring at the edge before upload and route flagged keys to a quarantine prefix for manual review
Object cannot be deleted during incident cleanup Object Lock compliance mode is retaining the key for the configured window Expected behaviour; wait out the retention period or use governance mode with s3:BypassGovernanceRetention for break-glass roles only

Frequently Asked Questions Jump to heading

Why customer-managed KMS keys instead of the default SSE-S3 keys? A customer-managed CMK gives you a CloudTrail entry for every decrypt, a key policy you can scope to a single VPC endpoint or store region, and automatic annual rotation. SSE-S3 (AES256) encrypts at rest but offers no per-principal access control or per-call audit, which retail privacy reviews increasingly require.

How short should pre-signed URL lifetimes be? Use the smallest window the capture workflow tolerates. A 10-minute default with a 15-minute ceiling covers slow in-store connectivity while leaving almost no replay window if a URL leaks. Bind the URL to one exact key and the PUT method so a leaked URL cannot be reused to overwrite other captures.

Do VPC endpoints remove the need for a bucket policy? No. Endpoints keep traffic off the public internet and let you deny dangerous actions at the network edge, but the bucket policy is still the authoritative control for encryption enforcement and principal scoping. Treat them as overlapping layers, not substitutes.

Where does PII redaction belong — edge or cloud? Blur faces and licence plates at the edge before upload so raw PII never lands in S3, then run Macie as a backstop to catch frames the edge model missed. The same principle governs the sanitisation rules described in Security Boundaries for Retail Image Data.

Back to top