# Presigned URLs (/tools/s3-gateway/presigned-urls)

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](#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](/tools/s3-gateway/quickstarts/aws-cli) already uses both.

## Create a download link

<Tabs items={["AWS CLI", "Python", "JavaScript"]}>
  <Tab value="AWS CLI">
    ```bash
    aws s3 presign "s3://$SHELBY_BUCKET/reports/q3.pdf" --expires-in 3600 --profile shelby
    ```

    The `shelby` profile comes from the [AWS CLI quickstart](/tools/s3-gateway/quickstarts/aws-cli).
    `aws s3 presign` only makes download (GET) links, and can't set the response overrides below.
  </Tab>

  <Tab value="Python">
    ```python
    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](/tools/s3-gateway/quickstarts/python).
    Keep its `region_name="default"`: a link signed for any other region is refused.
  </Tab>

  <Tab value="JavaScript">
    ```bash
    npm install @aws-sdk/s3-request-presigner
    ```

    ```ts
    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](/tools/s3-gateway/quickstarts/javascript). 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`.
  </Tab>
</Tabs>

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.

<Tabs items={["Python", "JavaScript"]}>
  <Tab value="Python">
    ```python
    url = s3.generate_presigned_url(
        "get_object",
        Params={
            "Bucket": bucket,
            "Key": "reports/q3.pdf",
            "ResponseContentDisposition": 'attachment; filename="q3-report.pdf"',
        },
        ExpiresIn=3600,
    )
    ```
  </Tab>

  <Tab value="JavaScript">
    ```ts
    const url = await getSignedUrl(
      s3,
      new GetObjectCommand({
        Bucket,
        Key: "reports/q3.pdf",
        ResponseContentDisposition: 'attachment; filename="q3-report.pdf"',
      }),
      { expiresIn: 3600 },
    );
    ```
  </Tab>
</Tabs>

| 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](#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.

<Tabs items={["Python", "JavaScript"]}>
  <Tab value="Python">
    ```python
    url = s3.generate_presigned_url(
        "head_object",
        Params={"Bucket": bucket, "Key": "reports/q3.pdf"},
        ExpiresIn=300,
    )
    ```
  </Tab>

  <Tab value="JavaScript">
    ```ts
    import { HeadObjectCommand } from "@aws-sdk/client-s3";

    const url = await getSignedUrl(
      s3,
      new HeadObjectCommand({ Bucket, Key: "reports/q3.pdf" }),
      { expiresIn: 300 },
    );
    ```
  </Tab>
</Tabs>

HEAD requests don't count toward your organization's
[concurrent downloads](/tools/s3-gateway/limits#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:

<Tabs items={["Python", "JavaScript"]}>
  <Tab value="Python">
    ```python title="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](/tools/s3-gateway/quickstarts/python). botocore comes with boto3, so
    there's nothing else to install.
  </Tab>

  <Tab value="JavaScript">
    ```bash
    npm install @aws-sdk/s3-request-presigner @aws-sdk/util-format-url
    ```

    ```ts title="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);
    }
    ```

    ```ts
    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.
  </Tab>
</Tabs>

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.

### 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 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](#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
  with `Content-Disposition: attachment` instead. See
  [Set the filename or type](#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](/tools/s3-gateway/limits#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](/protocol/pricing#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-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](https://console.shelby.xyz), 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](https://console.shelby.xyz). 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](#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](https://github.com/shelby/shelby-quickstart/tree/main/recipes/share-files)
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](/tools/s3-gateway/compatibility) ·
[Troubleshooting](/tools/s3-gateway/troubleshooting)
