A presigned URL returns 403
After this you can answer: What should you check when an S3 presigned URL returns 403?
Symptom
Section titled “Symptom”Troubleshooting scenario: a ticket image or poster upload returns S3 403 Forbidden with
“Request has expired”, ExpiredToken, SignatureDoesNotMatch or AccessDenied. Read the S3
error code before treating every 403 as the same problem.
Read the error first
Section titled “Read the error first”S3 received the request, so Reach succeeded. Under Reach / Allowed / Password, expiry or
signature failure is authentication, not a database password. AccessDenied points toward
Allowed: check the signing API task role, object prefix and conditions. A missing allow differs
from an explicit deny. Generating a signature locally proves neither permission nor object existence.
Decide
Section titled “Decide”flowchart TB A["S3 403"] --> B{"Error code or message?"} B -->|"expired"| C["Get fresh link with valid task credentials"] B -->|"signature mismatch"| D["Compare method, path, query and headers"] B -->|"access denied"| E["Check signer grants and explicit denies"] B -->|"browser hides response"| F["Check preflight and CORS separately"]Likely causes
Section titled “Likely causes”Ranked for MiniTickets’ short-lived links; the actual error overrides the order.
- The 120-second link expired, or its temporary signing credentials expired sooner.
- A PUT changed the signed content type, byte length or request URL; a GET link was used for a different method. Poster PUT also needs the returned encryption header.
- The API role lost permission, the key is outside an allowed prefix, or a policy denies access.
How to check
Section titled “How to check”Privately inspect browser Network: S3 error, method and headers; never share the signed URL. Compare the API task role’s S3 grants with S3 → bucket → Permissions. A rejected OPTIONS preflight belongs to the CORS case.
Set ASSET_BUCKET to the bucket name from S3. Read policy without issuing or printing a link.
export AWS_PROFILE=ticketliteaws s3api get-bucket-policy --bucket "$ASSET_BUCKET" --query Policy --output textaws iam list-role-policies --role-name minitickets-dev-api-task-roleSearch management events PutBucketPolicy and PutRolePolicy near a permission change.
GetObject/PutObject evidence requires S3 object data-event logging; ordinary Event history
does not include it. Local presigning itself makes no AWS API call.
Request a fresh link through the authorized API. Preserve the exact signed request and returned headers. Repair narrowly scoped signer permissions when needed; retain the bucket’s TLS-only deny. Do not store links as durable data: store object keys.
Prevent
Section titled “Prevent”Keep the 120-second lifetime and private/no-store responses. Test real SDK signing, upload headers and expired-link recovery without logging bearer links.
apps/api/src/queries/assets.ts → ASSET_URL_SECONDS;
apps/api/src/queries/posters.ts → signPosterPut;
Inspect private assets →.
Say it in 30 seconds
Section titled “Say it in 30 seconds”Advanced: A bearer link is a bounded permission
Whoever has a presigned URL can use it until it expires, subject to the signing principal’s
permissions. The URL cannot outlive temporary credentials used to sign it. AWS documents
expiry, permission and signature troubleshooting.
Poster uploads sign the file’s content type and byte length; the browser supplies
Content-Length for the File body. JavaScript must not set that forbidden header itself.
This bucket uses SSE-S3, not a customer-managed KMS key; investigate KMS only if that design
has actually changed. AccessDenied can also conceal a nonexistent object when the caller
lacks bucket-list permission; privately confirm the canonical key before broadening grants.
apps/web/src/features/events/hooks/use-upload-poster.ts → useUploadPoster.
Read this book online: https://book.theversehub.in/