Presigned URLs Are Not Authorization: Broken Object-Level Access Control in Cloud Storage
Presigned URLs are one of the most useful primitives in cloud engineering. They let a backend hand a short-lived, cryptographically signed link to a browser so that a large file can be uploaded or downloaded without routing gigabytes through an application server. They are also one of the easiest places to introduce an IDOR. A signature proves that the link was issued by someone with credentials; it does not prove that the person holding the link is allowed to access that object. Confusing those two statements has produced real data exposure in AWS S3, Azure Blob Storage, and Google Cloud Storage deployments.
1. What a Presigned URL Actually Proves
AWS Signature Version 4 presigned URLs embed the access key ID, the credential scope, the signed headers, the expiry, and an HMAC signature. The storage service recomputes the signature and checks that the request matches it. If the signature is valid and the key has permission, the request succeeds. The URL is therefore a bearer capability: anyone who possesses it can use it until it expires.
That property is fine when the URL is generated for the authenticated user's own object. It becomes a vulnerability when the object key is chosen by the client:
GET /api/download?key=invoices/2025/INV-1042.pdf HTTP/1.1
Host: app.example.com
Cookie: session=...
If the backend signs whatever key it receives, the user can substitute another tenant's key and receive a valid link. The signature is genuine. The authorization decision was never made.
2. Three Common Failure Patterns
2.1 Client-controlled object keys
The most direct flaw is a download or upload endpoint that accepts a full object key and signs it without checking ownership. Enumeration is trivial because object keys often follow predictable patterns: tenant/TENANT_ID/..., users/{uuid}/avatar.png, or invoices/2025/INV-{n}.pdf. Testers should request their own object, then swap the tenant identifier, user UUID, or invoice number while keeping the same session.
2.2 Over-broad signing credentials
Even when the key is derived server-side, the signing identity may have s3:GetObject on *. A presigned URL generated for one object can then be replayed with a modified path if the signature does not cover the path — which it always does for SigV4, but not necessarily for custom signing schemes or misconfigured proxies. More commonly, the backend signs a URL for a prefix rather than a single object, or issues credentials scoped to an entire bucket through STS and returns them to the client.
2.3 Long expiry and reusable links
SigV4 presigned URLs can be valid for up to seven days (604,800 seconds). A link pasted into a support ticket, chat, or browser history remains usable for that entire window. If the object contains personal data, the exposure outlives the incident response. Azure SAS tokens and Google Cloud signed URLs have similar configurable lifetimes; the security property depends entirely on choosing a short one.
3. Provider-Specific Details Worth Knowing
- AWS S3: SigV4 query authentication uses
X-Amz-Algorithm,X-Amz-Credential,X-Amz-Date,X-Amz-Expires,X-Amz-SignedHeaders, andX-Amz-Signature. The maximum expiry for SigV4 is seven days. Presigned URLs inherit the permissions of the signing principal, including session policies for temporary credentials. - Azure Blob Storage: A service SAS or user delegation SAS encodes permissions (
sp), start and expiry (st,se), resource (sr), and signature (sig). User delegation SAS is preferable because it is tied to an Entra ID identity and can be revoked by rotating the delegation key. - Google Cloud Storage: V4 signed URLs include
X-Goog-Algorithm,X-Goog-Credential,X-Goog-Date,X-Goog-Expires,X-Goog-SignedHeaders, andX-Goog-Signature. Signing must be performed with a service account that has only the required object-level permission.
In every case, the signature covers the HTTP method, host, path, and selected headers. Changing the path invalidates the signature. That is exactly why the vulnerability lives in the application: the application is the component that decides which path to sign.
4. A Safe Server-Side Pattern
The backend must resolve the object from an authorization decision, never from raw client input. The following Node.js example derives the key from the authenticated session and a database lookup:
// Express + AWS SDK v3
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: process.env.AWS_REGION });
app.get('/api/invoices/:invoiceId/download', requireAuth, async (req, res) => {
const invoice = await db.invoices.findOne({
where: { id: req.params.invoiceId, tenantId: req.user.tenantId }
});
if (!invoice) {
return res.status(404).json({ error: 'Not found' });
}
const command = new GetObjectCommand({
Bucket: process.env.INVOICE_BUCKET,
Key: invoice.storageKey // server-controlled value from the database
});
const url = await getSignedUrl(s3, command, { expiresIn: 300 });
return res.json({ url });
});
Notice three properties: the object key comes from the database, the query includes the tenant identifier, and the URL expires in five minutes. A user who changes invoiceId receives a 404 because the ownership filter fails before any signing occurs.
Uploads need the same discipline
For uploads, do not let the client choose an arbitrary key. Generate a server-side key, store the intended owner and content type, and sign a PutObject request with a short expiry. If the application accepts a client-supplied filename, normalize it, strip path separators, and prefix it with a server-generated identifier.
5. Testing Methodology
- Map every signing endpoint. Search the frontend bundle and API documentation for
presign,signed-url,download,upload,attachment, andexport. - Capture a legitimate request. Record the exact parameters, the returned URL, and the expiry.
- Mutate one identifier at a time. Change tenant IDs, user UUIDs, object names, and path segments. Keep the session cookie unchanged.
- Check the response, not just the status code. A 200 with a signed URL for another object is a finding even if the object is later inaccessible. Decode the URL and compare the path.
- Test expiry. Wait past the stated expiry and retry. If the link still works, the backend is not enforcing the parameter.
- Test method confusion. Try a signed
GETURL withPUTorDELETE. SigV4 should reject it; custom implementations sometimes do not.
# Inspect the query parameters of a captured presigned URL
python3 - <<'PY'
from urllib.parse import urlparse, parse_qs
u = urlparse('https://bucket.s3.amazonaws.com/invoices/1042.pdf?X-Amz-Algorithm=...')
for k, v in parse_qs(u.query).items():
print(k, '=', v[0])
print('path =', u.path)
PY
6. Detection and Hardening
- Authorize before signing. The signing function should accept an internal object reference, not a client string. Resolve ownership in the same transaction that produces the key.
- Scope credentials tightly. The signer should have
s3:GetObjectonly on the required prefix, never*. Use separate roles for uploads and downloads. - Keep expiries short. Five to fifteen minutes is usually enough for a browser download. Reserve longer lifetimes for offline batch jobs with a documented exception.
- Bind links to a user or session where possible. Cloud-native signed URLs cannot do this by themselves; put a short-lived application token in front of the object, or stream the file through an authenticated endpoint for highly sensitive data.
- Log issuance and use. Record who requested which object, when the URL was issued, and whether the storage access succeeded. Alert on a single identity requesting many distinct tenants in a short window.
- Prefer user delegation over account keys. On Azure, user delegation SAS avoids embedding a storage account key in application configuration. On AWS, use short-lived STS credentials rather than long-lived IAM user keys.
7. Conclusion
A presigned URL is a delegation of a narrow storage permission, not a substitute for an authorization check. The signature answers “was this link created by a trusted principal?” It does not answer “should this principal be allowed to read this object?” Applications that conflate the two produce IDORs that are invisible to signature validation and often missed by scanners. The fix is architectural: derive object keys from server-side ownership records, scope signing credentials to the minimum prefix, and keep the lifetime short enough that a leaked link is a brief incident rather than a permanent data leak.