Skip to content

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 default and path-style addressing. The client setup in each quickstart already uses both.
aws s3 presign "s3://$SHELBY_BUCKET/reports/q3.pdf" --expires-in 3600 --profile shelby

The 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-presigner
import { 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 parameterSDK parameterOverrides
response-content-dispositionResponseContentDispositionContent-Disposition
response-content-typeResponseContentTypeContent-Type
response-content-languageResponseContentLanguageContent-Language
response-content-encodingResponseContentEncodingContent-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:

folder_share.py
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-url
folder-share.ts
import 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

  1. They open the link in a browser as a normal link: clicked, or pasted into the address bar.
  2. Shelby checks the signature, sets a cookie for s3.beta.shelby.xyz, and redirects to a listing of the prefix. The listing is S3's ListObjectsV2 XML, not a file browser.
  3. 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.
RequestResult
List the prefix, or a narrower prefixAllowed
GET or HEAD an object under the prefixAllowed
An object outside the prefix403 "Folder cookie does not grant access to this object"
A listing outside the prefix, or of the whole bucket403 "Folder cookie cannot list outside its prefix"
An upload, delete or any other write403 "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 with fetch from 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 Authorization header 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.

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 with Content-Disposition: attachment instead. 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.

  • 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-store and Referrer-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

StatusMessageCauseFix
401Anonymous requests are not allowed for this endpointThe 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.
401Presigned URL has expiredThe expiry has passed, or the signing server's clock is behindSign a new link. Turn on automatic time sync where you sign.
401Presigned URL is not valid yetThe signing server's clock is aheadTurn on automatic time sync where you sign
401Invalid signatureThe 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.
401Presigned URLs require a public S3 AccessKeyIdIt was signed with something other than your access key ID, such as the secret in both fieldsSign with your access key ID, which starts with SHLB
400Presigned credential region must be defaultIt was signed for another regionSet the region to default
400X-Amz-Expires must be between 1 and 604800 secondsThe expiry is over 7 daysUse 1 to 604,800 seconds
405Presigned URLs only support GET and HEADIt's a presigned upload, delete or other writeUpload and delete from your server with your credentials
400Presigned URLs require a bucket and object keyIt's a presigned listing or bucket requestList from your server, or share a folder
400Query parameter is not supported for presigned GET: …It has response-cache-control, response-expires or versionIdRemove it. The other response-* overrides work.
400Unsigned x-amz header …The request sends an x-amz-* header that wasn't signedDon't add x-amz-* headers when you use the link
400Presigned query must percent-encode '+' as %2BA hand-built URL has a bare + in its queryEncode + as %2B. SDKs do this for you.
501NotImplemented: "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 linkSet responseChecksumValidation: "WHEN_REQUIRED" on the client, as shelby-s3.ts does, and sign again
503SlowDown, with Retry-After: 1 (S3 XML)Your organization has too many downloads in flightRetry after a second, and load fewer objects at once

Folder shares

StatusMessageCauseFix
400Folder share requires a non-empty prefixThe link was signed with an empty prefixShare a prefix such as contracts/customer-123/
401Anonymous requests are not allowed for this endpointThe 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 itOpen a share link that hasn't expired, in the same browser, as a normal link
403Folder cookie does not grant access to this objectThe object is outside the shared prefix, or the recipient opened another share sinceOpen the share link for that folder again
403Folder cookie cannot list outside its prefixThe listing is broader than the shared prefixList the shared prefix, or a prefix inside it
403Folder cookies allow only read operationsIt's an upload, delete or other writeWrite from your server with your credentials
400Folder cookie object reads do not support query parametersThe object URL has a query stringUse 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.

Compatibility matrix · Troubleshooting