Cross-Account Downloads and Failing Large Uploads
A fintech platform lets users upload a 400 MB monthly statement through a web front end. The front end calls an API Gateway endpoint backed by Lambda, which returns a presigned S3 URL; the browser then PUTs the file directly to a bucket in account A. Objects are encrypted with SSE-KMS using a customer managed key in account A. A partner team in account B downloads statements from their own application using an IAM role, PartnerReader. Three problems surfaced this week: users on slow connections get 403 SignatureDoesNotMatch roughly five minutes into an upload; the partner role gets 403 AccessDenied on GetObject even though the bucket policy grants s3:GetObject to arn:aws:iam::B:role/PartnerReader; and a security review found that the mobile client ships an IAM user's long-term access key.
1. Why do large uploads fail partway through, and how should presigned uploads be designed for 400 MB files?
Read the worked answer
The URL expires mid-transfer. The Lambda sets ExpiresIn to 300 seconds. SigV4 presigned URLs can live up to 7 days, but the signature is validated when the request is received, and slow uplinks take far longer than 5 minutes for 400 MB. The client also often loses continuation support: a single PUT is all-or-nothing, so a timeout means starting over.
Do not simply extend the expiry to 12 hours. A long-lived URL is a bearer credential that leaks through browser history, logs, and proxies.
Correct design: use S3 multipart upload. The API calls CreateMultipartUpload, then the backend issues one short-lived presigned URL per part (UploadPart), 25 MB each, generated just before the client uploads that part. Part sizes must be at least 5 MiB (except the last) and at most 5 GiB, with a maximum of 10,000 parts. The client retries only the failed part and completes with CompleteMultipartUpload.
Add abort lifecycle rules on incomplete multipart uploads (for example 7 days) so abandoned uploads do not accrue storage charges, and enforce size and content-type limits in the presigned conditions so the URL cannot be repurposed to write a larger object. Return part URLs with individual, short expiries rather than one long-lived credential.
2. The bucket policy clearly allows s3:GetObject for the partner role, yet the partner still gets AccessDenied. Explain the cause and the exact fix.
Read the worked answer
Because the objects are SSE-KMS encrypted, S3 permission is only half the authorization. When a caller reads a KMS-encrypted object, S3 calls KMS on the caller's behalf, and KMS evaluates the caller's permission against the key. The bucket policy never grants that.
Two independent gates must both open:
- S3 side - bucket policy (or identity policy) allowing
s3:GetObjecton the object ARN. Already done. - KMS side - the caller needs
kms:Decrypton the CMK. For a cross-account caller, the key policy in account A must delegate to account B (for examplePrincipal: {"AWS":"arn:aws:iam::B:root"}withkms:Decrypt, typically conditioned onkms:ViaService: s3.<region>.amazonaws.comand the caller account inaws:PrincipalArn). Account B's own identity policy must then allow the same action. Neither policy alone is sufficient.
Fix: prefer a key-policy statement scoped to the partner role ARN with a kms:ViaService and s3:prefix-style condition, rather than issuing a KMS grant - grants are not visible in the key policy and get lost when the grant is retired or the key is rotated across accounts later.
Also verify the partner's requests are not blocked by an S3 Block Public Access or an explicit Deny on non-TLS access, and check CloudTrail for the kms:Decrypt denial event to confirm which gate is closed before changing anything.
3. The mobile client embeds IAM user access keys. What is the correct target architecture, and what do you do this quarter given the front end cannot be rebuilt immediately?
Read the worked answer
Target state: no long-term credentials anywhere in a distributed client. Use Amazon Cognito - a user pool for identity, an identity pool that exchanges the token for short-lived STS credentials, and an IAM role whose permissions are scoped to exactly one prefix in one bucket (s3:PutObject on uploads/${cognito-identity.amazonaws.com:sub}/*). S3 access can be granted directly, or with a Lambda-issued presigned URL after the API authorizer validates the Cognito token. Alternative for server-side flows: API Gateway authorizer → Lambda → sts:AssumeRole with a session policy further narrowing the blast radius.
Interim actions this quarter, in priority order:
- Treat the shipped key as compromised: rotate it and monitor CloudTrail for its access key ID;
DeactivateAccessKeyfirst rather than deleting so you can prove recovery. - Constrain the IAM user's policy to the single upload prefix and add
aws:SourceIp/VPC conditions where feasible, so a leaked key cannot read other tenants' data. - Ship a client update that requests credentials at runtime. Even a minimal change - fetch credentials or a presigned URL from an authenticated endpoint instead of compiling them in - removes the extraction target.
Do not accept "the app cannot change" as a reason to keep the key; the key is the highest-severity finding because it can never be un-leaked.