Presigned URLs
Share an object or a folder through a link that expires, so the recipient can download it without Shelby credentials.
A presigned URL lets someone download an object without Shelby credentials. Your server signs
the URL with your access key ID and secret, then hands it out. Until the URL expires, anyone
who has it can read that one object, from a browser, curl or any HTTP client.
On Shelby, presigned URLs are for downloads:
- GET and HEAD only. You can't presign an upload, a delete or a listing. Do those from your server with your credentials.
- One object per URL. To share everything under a prefix, share a folder.
- Up to 7 days. Set the expiry anywhere from 1 second to 604,800 seconds (7 days).
- Signed with region
defaultand path-style addressing. The client setup in each quickstart already uses both.
Create a download link
aws s3 presign "s3://$SHELBY_BUCKET/reports/q3.pdf" --expires-in 3600 --profile shelbyThe shelby profile comes from the AWS CLI quickstart.
aws s3 presign only makes download (GET) links, and can't set the response overrides below.
from shelby_s3 import bucket, s3
url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": bucket, "Key": "reports/q3.pdf"},
ExpiresIn=3600,
)shelby_s3.py is the client from the Python quickstart.
Keep its region_name="default": a link signed for any other region is refused.
npm install @aws-sdk/s3-request-presignerimport { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { Bucket, s3 } from "./shelby-s3.ts";
const url = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket, Key: "reports/q3.pdf" }),
{ expiresIn: 3600 },
);shelby-s3.ts is the client from the
JavaScript quickstart. Its region: "default" and
forcePathStyle: true are both part of the signature. Keep its
responseChecksumValidation: "WHEN_REQUIRED" too: without it, the SDK adds
x-amz-checksum-mode to the link, and Shelby refuses the link with 501 NotImplemented.
Each example signs a link that works for an hour (3,600 seconds). The recipient needs no
credentials and no S3 client: curl "$url" works, and so does opening the link in a browser.
In an app, sign the link on your server when a signed-in user asks for it, after checking that
they may read the object, and return it to them.
Pass the URL on exactly as it was signed. The endpoint, method, path, expiry and every query parameter are part of the signature, so changing any of them breaks the link.
Set the filename or type
A GET link can override some headers of the response, without changing the stored object. The
most useful is Content-Disposition: attachment makes a browser save the file under the
name you choose, instead of showing it.
url = s3.generate_presigned_url(
"get_object",
Params={
"Bucket": bucket,
"Key": "reports/q3.pdf",
"ResponseContentDisposition": 'attachment; filename="q3-report.pdf"',
},
ExpiresIn=3600,
)const url = await getSignedUrl(
s3,
new GetObjectCommand({
Bucket,
Key: "reports/q3.pdf",
ResponseContentDisposition: 'attachment; filename="q3-report.pdf"',
}),
{ expiresIn: 3600 },
);| Query parameter | SDK parameter | Overrides |
|---|---|---|
response-content-disposition | ResponseContentDisposition | Content-Disposition |
response-content-type | ResponseContentType | Content-Type |
response-content-language | ResponseContentLanguage | Content-Language |
response-content-encoding | ResponseContentEncoding | Content-Encoding |
response-cache-control and response-expires are refused with 400, because Shelby sets
caching headers on presigned responses itself. See Keep links safe.
Check an object without downloading it
A HEAD link returns the object's size, type and ETag without its body. Presign head_object
or HeadObjectCommand, then send a HEAD request with the link, for example with
curl -I "$url". The method is part of the signature, so a HEAD link only works for HEAD, and
a GET link only for GET.
url = s3.generate_presigned_url(
"head_object",
Params={"Bucket": bucket, "Key": "reports/q3.pdf"},
ExpiresIn=300,
)import { HeadObjectCommand } from "@aws-sdk/client-s3";
const url = await getSignedUrl(
s3,
new HeadObjectCommand({ Bucket, Key: "reports/q3.pdf" }),
{ expiresIn: 300 },
);HEAD requests don't count toward your organization's concurrent downloads.
Share a folder
A folder share gives someone read access to everything under a prefix, through one link. It's
a Shelby extension, not an S3 operation: you presign a GET of Shelby's /_auth/folder path,
with the bucket and prefix as query parameters, using the same access key ID and secret.
generate_presigned_url and getSignedUrl only sign S3 operations, so these examples sign the
request directly:
import os
from urllib.parse import quote, urlencode
from botocore.auth import S3SigV4QueryAuth
from botocore.awsrequest import AWSRequest
from botocore.credentials import Credentials
from shelby_s3 import bucket, s3
def folder_share_url(prefix: str, expires: int = 600) -> str:
"""Sign a link that lets whoever opens it read everything under prefix."""
query = urlencode({"bucket": bucket, "prefix": prefix}, quote_via=quote)
request = AWSRequest(
method="GET", url=f"{s3.meta.endpoint_url}/_auth/folder?{query}"
)
credentials = Credentials(
os.environ["SHELBY_ACCESS_KEY_ID"], os.environ["SHELBY_SECRET_ACCESS_KEY"]
)
S3SigV4QueryAuth(credentials, "s3", s3.meta.region_name, expires=expires).add_auth(
request
)
return request.url
share_url = folder_share_url("contracts/customer-123/")folder_share_url takes the endpoint, region and bucket from shelby_s3.py, the client from
the Python quickstart. botocore comes with boto3, so
there's nothing else to install.
npm install @aws-sdk/s3-request-presigner @aws-sdk/util-format-urlimport type { S3Client } from "@aws-sdk/client-s3";
import { S3RequestPresigner } from "@aws-sdk/s3-request-presigner";
import { formatUrl } from "@aws-sdk/util-format-url";
/** Sign a link that lets whoever opens it read everything under Prefix. */
export async function getSignedFolderUrl(
s3: S3Client,
{ Bucket, Prefix }: { Bucket: string; Prefix: string },
{ expiresIn = 600 }: { expiresIn?: number } = {},
): Promise<string> {
if (Prefix === "") throw new Error("Prefix must not be empty");
const endpoint = await s3.config.endpoint?.();
if (endpoint?.protocol !== "https:") {
throw new Error("Folder shares need an HTTPS endpoint");
}
const presigner = new S3RequestPresigner({ ...s3.config });
const signed = await presigner.presign(
{
method: "GET",
protocol: endpoint.protocol,
hostname: endpoint.hostname,
port: endpoint.port,
path: `${endpoint.path.replace(/\/+$/, "")}/_auth/folder`,
query: { bucket: Bucket, prefix: Prefix },
headers: {},
},
{ expiresIn },
);
return formatUrl(signed);
}import { getSignedFolderUrl } from "./folder-share.ts";
import { Bucket, s3 } from "./shelby-s3.ts";
const shareUrl = await getSignedFolderUrl(s3, {
Bucket,
Prefix: "contracts/customer-123/",
});getSignedFolderUrl signs with the client from shelby-s3.ts, so it uses the same endpoint,
region and credentials as the rest of your code.
Both examples sign a link that's valid for 10 minutes. As with object links, the most is 7
days. The prefix can't be empty, so a folder share can't cover a whole bucket. If it doesn't
end in /, Shelby adds one: contracts/customer-123 shares contracts/customer-123/, not
contracts/customer-1234/.
What the recipient does
- They open the link in a browser as a normal link: clicked, or pasted into the address bar.
- Shelby checks the signature, sets a cookie for
s3.beta.shelby.xyz, and redirects to a listing of the prefix. The listing is S3'sListObjectsV2XML, not a file browser. - In the same browser, they open any object under the prefix by its plain URL,
https://s3.beta.shelby.xyz/<bucket>/<key>. The URL has no signature and no query string: the cookie grants access. Each<Key>in the listing is an object's full key.
What the cookie allows
| Request | Result |
|---|---|
| List the prefix, or a narrower prefix | Allowed |
| GET or HEAD an object under the prefix | Allowed |
| An object outside the prefix | 403 "Folder cookie does not grant access to this object" |
| A listing outside the prefix, or of the whole bucket | 403 "Folder cookie cannot list outside its prefix" |
| An upload, delete or any other write | 403 "Folder cookies allow only read operations" |
- Access ends when the link expires. The cookie lasts until the link's own expiry, not for a fresh period from when it's opened. A 10-minute link opened after 8 minutes gives 2 minutes of access.
- A browser holds one folder share at a time. Opening a second share link replaces the first.
- The cookie only goes with direct visits. It's
SameSite=Lax, so the browser sends it when the recipient opens a Shelby URL themselves, from the listing, a link or the address bar. It doesn't send it withfetchfrom another site, or for an<img>or<video>on another site's page. To show files in your own page, use presigned object links instead. - Credentials take priority. A request that carries an
Authorizationheader or a presigned signature is checked on those credentials alone, and the cookie is ignored.
There's no way to revoke a single share. It ends when it expires, or when you disable the key that signed it. See Keep links safe.
Use links in a browser
A presigned link is an ordinary HTTPS URL, so a browser can use it anywhere it loads a URL from another site:
- Open it, or link to it with
<a href="…">. - Show it with
<img src="…">,<video src="…">or<audio src="…">. - Save it as a file. Browsers ignore
<a download>on links to another site, so sign the link withContent-Disposition: attachmentinstead. See Set the filename or type.
Don't build on fetch() or XMLHttpRequest from your page to Shelby yet. You can't configure
CORS on Shelby (PutBucketCors isn't supported), so there's no cross-origin policy you can rely
on. If your script needs an object's bytes, fetch them on your server.
Browsers can't upload either. There are no presigned uploads, and your secret must never reach a browser. Upload from your server.
Each GET through a presigned link counts toward your organization's
concurrent downloads, and so does each range
request a video or audio player makes as it plays. Past the limit, Shelby returns
503 SlowDown with Retry-After: 1. A page that loads many images or videos from Shelby at
once can hit it, so load fewer at a time.
Every download through a link also counts toward your egress, each time it's opened. A widely shared link or a popular page can add to your bill on the Paid plan, or use up the Free plan's monthly egress and block downloads until it renews.
Keep links safe
- Sign on a server you trust. Your secret never goes in browser or mobile app code. The URL carries your access key ID and a signature, never the secret.
- Treat the whole URL as a password. Anyone who has it can read the object, or the folder, until it expires. Keep links out of logs, analytics, error reports, support tickets and public HTML.
- Use the shortest expiry that works. Minutes are enough for a link someone opens straight away. 7 days is the most Shelby allows.
- Don't cache presigned responses. Every presigned response, including an error, has
Cache-Control: private, no-storeandReferrer-Policy: no-referrer. Don't put a CDN or shared cache in front of these links. - Revoke by disabling the key. You can't revoke one link. Disabling or rotating the access key that signed it, in the console, stops all of that key's links and folder shares after a short delay. It also stops everything else that uses the key.
Errors
Shelby refuses most bad links with a short plain-text message, not an S3 XML error. To see it,
open the link with curl -i "$url".
Presigned URLs
| Status | Message | Cause | Fix |
|---|---|---|---|
401 | Anonymous requests are not allowed for this endpoint | The URL has no SigV4 signature. boto3 configured with region us-east-1 makes legacy SigV2 links, with AWSAccessKeyId, Signature and Expires parameters, which Shelby doesn't accept. A URL that lost its query string fails the same way. | Set the region to default and sign again. Pass on the whole URL. |
401 | Presigned URL has expired | The expiry has passed, or the signing server's clock is behind | Sign a new link. Turn on automatic time sync where you sign. |
401 | Presigned URL is not valid yet | The signing server's clock is ahead | Turn on automatic time sync where you sign |
401 | Invalid signature | The URL changed after signing, or it was signed with a secret that doesn't match the access key ID. A key with a . or .. path segment, such as reports/../q3.pdf, also fails this way: it can't be presigned. | Pass the URL on exactly as signed. Copy your access key ID and secret again from the console. For a key with . or .. segments, download it from your server instead. |
401 | Presigned URLs require a public S3 AccessKeyId | It was signed with something other than your access key ID, such as the secret in both fields | Sign with your access key ID, which starts with SHLB |
400 | Presigned credential region must be default | It was signed for another region | Set the region to default |
400 | X-Amz-Expires must be between 1 and 604800 seconds | The expiry is over 7 days | Use 1 to 604,800 seconds |
405 | Presigned URLs only support GET and HEAD | It's a presigned upload, delete or other write | Upload and delete from your server with your credentials |
400 | Presigned URLs require a bucket and object key | It's a presigned listing or bucket request | List from your server, or share a folder |
400 | Query parameter is not supported for presigned GET: … | It has response-cache-control, response-expires or versionId | Remove it. The other response-* overrides work. |
400 | Unsigned x-amz header … | The request sends an x-amz-* header that wasn't signed | Don't add x-amz-* headers when you use the link |
400 | Presigned query must percent-encode '+' as %2B | A hand-built URL has a bare + in its query | Encode + as %2B. SDKs do this for you. |
501 | NotImplemented: "A query parameter you provided implies functionality that is not implemented" (S3 XML) | The AWS SDK for JavaScript added x-amz-checksum-mode to the link | Set responseChecksumValidation: "WHEN_REQUIRED" on the client, as shelby-s3.ts does, and sign again |
503 | SlowDown, with Retry-After: 1 (S3 XML) | Your organization has too many downloads in flight | Retry after a second, and load fewer objects at once |
Folder shares
| Status | Message | Cause | Fix |
|---|---|---|---|
400 | Folder share requires a non-empty prefix | The link was signed with an empty prefix | Share a prefix such as contracts/customer-123/ |
401 | Anonymous requests are not allowed for this endpoint | The browser didn't send a folder cookie: the share expired, the URL was opened in another browser, or another site's page fetched or embedded it | Open a share link that hasn't expired, in the same browser, as a normal link |
403 | Folder cookie does not grant access to this object | The object is outside the shared prefix, or the recipient opened another share since | Open the share link for that folder again |
403 | Folder cookie cannot list outside its prefix | The listing is broader than the shared prefix | List the shared prefix, or a prefix inside it |
403 | Folder cookies allow only read operations | It's an upload, delete or other write | Write from your server with your credentials |
400 | Folder cookie object reads do not support query parameters | The object URL has a query string | Use the plain object URL |
The share-files recipe runs all of this end to end: a small server signs object links and folder shares, and a recipient with no credentials uses them.