---
title: Upload and privately share files on Wasabi with TypeScript
description: Store documents in a private Wasabi bucket, verify each upload, and share them through expiring links you can revoke, with the endpoint for every region.
sidebar:
  label: Private sharing on Wasabi
seo:
  title: Private file sharing on Wasabi with TypeScript
related:
  - /docs/adapters/wasabi
  - /docs/api/url
  - /guides/hetzner-object-storage-typescript
  - /guides/presigned-upload-validation
  - /docs/provider-gaps
---

The bucket stays private and its object URLs never leave your server. Your server writes each document with `files-sdk/wasabi`, reads it back with `head()` before anyone gets a link, and hands out a link on your own domain. Each click on that link checks your database, then redirects to a presigned `GET` URL that lives for 60 seconds. The bytes come straight from Wasabi; your app only decides whether the link still works.

Two Wasabi rules shape that design. A presigned URL can't live longer than 7 days and can't be called back once it's sent, so the long-lived, revocable link has to be yours. And Wasabi bills a deleted object for the rest of its minimum storage period (90 days on pay-as-you-go), so deleting a document after sharing it ends access, not the storage charge.

## Before you start

- A Wasabi account and a bucket. You choose the bucket's region when you create it, and the endpoint follows from that choice. [Wasabi's docs](https://docs.wasabi.com/docs/how-do-i-generate-pre-signed-urls-for-temporary-access-with-wasabi) state that buckets and objects are private by default; this guide keeps them that way.
- An access key for a sub-user whose policy covers only that bucket. Root-account keys carry full administrative access, billing included, according to [Wasabi's API authentication docs](https://docs.wasabi.com/apidocs/authentication-with-s3-api). A server that reads and writes documents doesn't need that.
- Somewhere to store share records: any database your app already uses.
- Written against files-sdk 3.0, `@aws-sdk/client-s3` 3.1148, and Next.js 16.4. The two route handlers use only `Request` and `Response`, so they move to Hono, SvelteKit, or `Bun.serve` without changes.

```package-install
files-sdk @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @aws-sdk/s3-presigned-post
```

`files-sdk/wasabi` wraps the SDK's S3 adapter, which imports all three AWS packages, so you need them even though this guide never creates a presigned `POST`. Add `@aws-sdk/lib-storage` only if you pass `onProgress`, set `multipart`, or upload a stream of unknown length.

## Point the adapter at the bucket's region

Pass the region code, not a URL. The adapter builds the endpoint `https://s3.<region>.wasabisys.com`, uses virtual-hosted addressing (`documents.s3.eu-central-2.wasabisys.com`), and signs requests with the same region string. Wasabi's [service URL list](https://docs.wasabi.com/docs/service-urls-for-wasabis-storage-regions) says to use the URL that matches the bucket's location:

| `region` | Location | Endpoint the adapter uses |
| --- | --- | --- |
| `us-east-1` | N. Virginia | `s3.us-east-1.wasabisys.com` (Wasabi's primary name for it is `s3.wasabisys.com`) |
| `us-east-2` | N. Virginia | `s3.us-east-2.wasabisys.com` |
| `us-central-1` | Texas | `s3.us-central-1.wasabisys.com` |
| `us-west-1` | Oregon | `s3.us-west-1.wasabisys.com` |
| `us-west-2` | San Jose | `s3.us-west-2.wasabisys.com` |
| `ca-central-1` | Toronto | `s3.ca-central-1.wasabisys.com` |
| `eu-central-1` | Amsterdam | `s3.eu-central-1.wasabisys.com` |
| `eu-central-2` | Frankfurt | `s3.eu-central-2.wasabisys.com` |
| `eu-west-1` | United Kingdom | `s3.eu-west-1.wasabisys.com` |
| `eu-west-2` | Paris | `s3.eu-west-2.wasabisys.com` |
| `eu-west-3` | United Kingdom | `s3.eu-west-3.wasabisys.com` |
| `eu-south-1` | Milan | `s3.eu-south-1.wasabisys.com` |
| `ap-northeast-1` | Tokyo | `s3.ap-northeast-1.wasabisys.com` |
| `ap-northeast-2` | Osaka | `s3.ap-northeast-2.wasabisys.com` |
| `ap-southeast-1` | Singapore | `s3.ap-southeast-1.wasabisys.com` |
| `ap-southeast-2` | Sydney | `s3.ap-southeast-2.wasabisys.com` |

The names match AWS region codes, but the endpoints are Wasabi's own. The adapter reads its credentials from two environment variables:

```bash title=".env.local"
WASABI_ACCESS_KEY_ID=your-access-key-id
WASABI_SECRET_ACCESS_KEY=your-secret-access-key
```

It doesn't read the bucket or region from the environment, so pass them in code:

```ts title="lib/files.ts" lineNumbers
import { createFiles } from "files-sdk";
import { wasabi } from "files-sdk/wasabi";

export const files = createFiles({
  adapter: wasabi({
    bucket: "documents",
    // The region the bucket was created in. It picks the endpoint
    // (https://s3.eu-central-2.wasabisys.com) and the signing region.
    region: "eu-central-2",
  }),
});
```

`wasabi()` checks its configuration when it's called, which here is when `lib/files.ts` first loads. A missing region or credential fails the first request that imports the module, with the messages listed under [Troubleshooting](#troubleshooting).

Leave `publicBaseUrl` unset. With it, a plain `url(key)` returns an unsigned `https://documents.s3…/<key>` link, which only works on a bucket you've made public. The links in this guide pass `expiresIn`, which signs either way.

## Store a document and check what landed

The server picks the key. The user's filename never becomes part of it: it goes into your database and only comes back as the download's file name. That keeps odd characters, path tricks, and guessable names out of the bucket.

```ts title="lib/documents.ts" lineNumbers
import { files } from "@/lib/files";
import { revokeSharesForKey } from "@/lib/shares";

export async function storeDocument(ownerId: string, file: File) {
  const key = `docs/${ownerId}/${crypto.randomUUID()}`;
  const contentType = file.type || "application/octet-stream";

  await files.upload(key, file, { contentType, metadata: { owner: ownerId } });

  // `upload()` reports what was sent; `head()` reports what Wasabi stored.
  const stored = await files.head(key);
  if (
    stored.size !== file.size ||
    stored.contentType !== contentType ||
    stored.metadata?.owner !== ownerId
  ) {
    await files.delete(key);
    throw new Error(`Stored object ${key} doesn't match the upload`);
  }
  return {
    contentType: stored.contentType,
    etag: stored.etag,
    key,
    size: stored.size,
  };
}

export async function deleteDocument(key: string) {
  await revokeSharesForKey(key);
  await files.delete(key);
}
```

For a `File` body, the `size` that `upload()` returns is the length the SDK measured before sending, not a figure from Wasabi. `head()` is a separate request that returns the stored size, content type, ETag, and metadata, so a mismatch means the object you'd be sharing isn't the one the user sent. The function deletes it and fails rather than hand out a link to it.

Keep metadata keys lowercase. S3 [stores user-defined metadata keys in lowercase](https://docs.aws.amazon.com/AmazonS3/latest/userguide/UsingMetadata.html#UserMetadata), so a key named `ownerId` comes back from `head()` as `ownerid` and the comparison above would fail. Against a local MinIO server, `head()` returned exactly that.

The route that receives the upload checks the session, stores the file, and creates the first share link:

```ts title="app/api/documents/route.ts" lineNumbers
import { getSession } from "@/lib/auth";
import { storeDocument } from "@/lib/documents";
import { createShareLink } from "@/lib/sharing";

export async function POST(req: Request) {
  const session = await getSession(req.headers);
  if (!session) {
    return Response.json({ error: "Sign in first" }, { status: 401 });
  }

  const form = await req.formData();
  const file = form.get("file");
  if (!(file instanceof File)) {
    return Response.json({ error: "Attach a file" }, { status: 400 });
  }

  const doc = await storeDocument(session.user.id, file);
  const link = await createShareLink({
    days: 14,
    filename: file.name,
    key: doc.key,
    ownerId: session.user.id,
  });
  return Response.json({ link, size: doc.size });
}
```

`getSession` stands in for your auth library: Auth.js, Clerk, Better Auth, or your own cookie check. This guide assumes it takes request headers and returns `{ user: { id: string } }` or `null`.

`req.formData()` holds the whole file in memory, and every byte passes through your server. That suits documents of a few megabytes, but hosts cap request bodies (Vercel Functions at 4.5 MB). For larger files, let the browser upload straight to Wasabi with a presigned URL, and run the same `head()` check when the client reports the key it uploaded. [Build a Next.js file uploader with Cloudflare R2](/guides/nextjs-r2-file-upload) shows that gateway setup, and the bucket will need a [CORS rule](https://docs.wasabi.com/docs/cross-origin-resource-sharing-cors) for your origin. Wasabi's presigned URL docs include a `POST` example with a `content-length-range` condition, which is the form `signedUploadUrl({ maxSize })` produces on this adapter; [Enforce file-size and content-type limits on presigned uploads](/guides/presigned-upload-validation) compares the options.

## Share through a link you control

A presigned URL is a bearer token: whoever holds it can download the object until it expires. You could email one directly, but it's a poor share link:

|  | Presigned URL sent directly | Link on your domain (this guide) |
| --- | --- | --- |
| Longest lifetime | 7 days. `url()` throws above 604,800 seconds. | As long as your share record says |
| Stop it early | Only by deleting the object | Mark the share revoked |
| What the recipient sees after it ends | Wasabi's XML error | A page you write |
| Your server's part in each download | None | One redirect |

The share record is a row in your database. This guide calls four functions on it; implement them however your app stores data.

```ts title="lib/shares.ts" lineNumbers
// Backed by your database. These are the calls this guide makes.
export interface Share {
  id: string; // random; the link's only secret
  key: string; // Wasabi object key
  ownerId: string;
  filename: string; // shown in the recipient's save dialog
  expiresAt: Date;
  revokedAt: Date | null;
}

export declare function insertShare(share: Share): Promise<void>;
export declare function findShare(id: string): Promise<Share | null>;
export declare function revokeShare(id: string): Promise<void>;
export declare function revokeSharesForKey(key: string): Promise<void>;
```

Creating a link writes a record. Following one signs a URL:

```ts title="lib/sharing.ts" lineNumbers
import { files } from "@/lib/files";
import { insertShare } from "@/lib/shares";

const DAY_MS = 24 * 60 * 60 * 1000;

export async function createShareLink(input: {
  key: string;
  ownerId: string;
  filename: string;
  days: number;
}) {
  const id = crypto.randomUUID();
  await insertShare({
    expiresAt: new Date(Date.now() + input.days * DAY_MS),
    filename: input.filename,
    id,
    key: input.key,
    ownerId: input.ownerId,
    revokedAt: null,
  });
  return `https://app.example.com/s/${id}`;
}

/** `attachment` with an ASCII fallback name plus the UTF-8 original (RFC 6266). */
export function contentDisposition(filename: string) {
  const fallback = filename.replace(/[^\x20-\x7e]|["\\]/gu, "_");
  const encoded = encodeURIComponent(filename).replace(
    /['()*]/gu,
    (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
  );
  return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`;
}

/** A presigned GET that only has to survive the redirect. */
export function signedDownloadUrl(key: string, filename: string) {
  return files.url(key, {
    expiresIn: 60,
    responseContentDisposition: contentDisposition(filename),
  });
}
```

The public route is what recipients open. It needs no session; holding the link is the permission.

```ts title="app/s/[id]/route.ts" lineNumbers
import { findShare } from "@/lib/shares";
import { signedDownloadUrl } from "@/lib/sharing";

const text = (body: string, status: number) =>
  new Response(body, {
    headers: { "content-type": "text/plain; charset=utf-8" },
    status,
  });

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const share = await findShare(id);
  if (!share) {
    return text("This link doesn't exist.", 404);
  }
  if (share.revokedAt || share.expiresAt.getTime() <= Date.now()) {
    return text("This link has expired. Ask the sender for a new one.", 410);
  }

  const url = await signedDownloadUrl(share.key, share.filename);
  return new Response(null, {
    headers: { "cache-control": "no-store", location: url },
    status: 302,
  });
}
```

What each choice in those two files buys you:

- **The share ID is the secret.** `crypto.randomUUID()` gives 122 random bits, so links can't be guessed. If recipients must also sign in, check a session in this route before redirecting.
- **`expiresIn: 60`** only has to cover the redirect, because the browser requests the presigned URL straight away. Each click mints a fresh URL. Wasabi's [presigned URL docs](https://docs.wasabi.com/docs/how-do-i-generate-pre-signed-urls-for-temporary-access-with-wasabi) say you "must start the action before the expiration date and time", so the 60 seconds limit when a download can start, not how long it can take.
- **`responseContentDisposition`** asks Wasabi to send the file as an attachment under its original name. The browser saves the file instead of rendering it, so an uploaded HTML or SVG file can't run script at the bucket's origin. `contentDisposition()` writes an ASCII fallback and the UTF-8 name, so a name like `Résumé (final).pdf` survives. Against a local MinIO server, the response carried that header back unchanged.
- **`cache-control: no-store`** stops a browser or proxy from caching a redirect to a URL that stops working a minute later.

To revoke a link, check that the signed-in user owns it, then call `revokeShare(id)`. The next click gets the `410` page. A presigned URL minted in the previous 60 seconds keeps working until it expires; that minute is the revocation window.

### When a direct presigned URL is enough

If you don't need revocation or your own expiry page, for example a one-off link pasted into a support ticket, sign once and send the URL itself:

```ts title="lib/direct.ts" lineNumbers
import { files } from "@/lib/files";
import { contentDisposition } from "@/lib/sharing";

// A direct presigned URL: no app round trip, no revocation, at most 7 days.
export function directShareUrl(key: string, filename: string, days: number) {
  return files.url(key, {
    expiresIn: days * 24 * 60 * 60,
    responseContentDisposition: contentDisposition(filename),
  });
}
```

Wasabi documents a 7-day maximum for URLs signed with an IAM user's keys, and that's also the SigV4 limit. The adapter checks it before signing, so `directShareUrl(key, name, 8)` throws a `FilesError` with code `Invalid` and `permanent: true` instead of returning a URL that won't work.

## What a recipient sees when a link stops working

Through your link, they get your own page: `404` for an unknown ID, `410` for an expired or revoked share.

With a presigned URL they're talking to Wasabi, which answers with an XML error document instead of your page. Wasabi's [S3 API error table](https://docs.wasabi.com/apidocs/http-methods-compatibility-and-error-handling) lists `403` as "Forbidden / SignatureFail" and `404` for a missing bucket or object. Against a local MinIO server, also S3-compatible, the URLs this guide produces failed like this:

| What happened | Status | Error code in the body |
| --- | --- | --- |
| The URL expired | `403` | `AccessDenied`, message `Request has expired` |
| Someone edited the query string, such as raising `X-Amz-Expires` | `403` | `SignatureDoesNotMatch` |
| Someone edited the key in the path | `403` | `SignatureDoesNotMatch` |
| The object was deleted | `404` | `NoSuchKey` |

Raising `X-Amz-Expires` doesn't extend a link because the expiry is part of what the signature covers. Wasabi's wording in the error body may differ from MinIO's. To see your bucket's exact response, sign a URL with `expiresIn: 5`, wait, and request it with `curl -i`.

## Deleting after sharing doesn't stop the bill

`deleteDocument()` in `lib/documents.ts` revokes every share for the key, then deletes the object. Outstanding presigned URLs fail from then on, because the object is gone.

The storage charge doesn't end there. Wasabi's [minimum storage duration policy](https://docs.wasabi.com/docs/how-does-wasabis-minimum-storage-duration-policy-work) bills an object deleted before its minimum period as Timed Deleted Storage for the remaining days. The [pricing FAQ](https://wasabi.com/pricing/faq) puts that period at 90 days on pay-as-you-go; other pricing models use different periods, and Wasabi's docs describe accounts moved to 30. In Wasabi's own example, an object stored on day 1 and deleted on day 16 is billed for 15 days of active storage and 75 days of deleted storage. [Overwrites count as deletes](https://docs.wasabi.com/docs/billing-for-overwrites-and-deleted-storage), so re-uploading to the same key doesn't avoid it.

What that means for a sharing app:

- **Delete to end access, not to save money.** A document deleted the day after it's shared costs the same as one kept for 90 days. Revoking the share already ends access through your link.
- **Short-lived files may belong elsewhere.** If most documents only need to exist for a few days, Wasabi's policy page itself says that "it may be more cost-effective for you to store this data in AWS".
- **Automate cleanup with a lifecycle rule.** A [lifecycle rule](https://docs.wasabi.com/docs/lifecycle) filtered to the `docs/` prefix can delete objects a set number of days after creation. The minimum period still applies to each object.

Downloads have their own limit. Wasabi's free egress is meant for accounts whose monthly downloads are no larger than their active storage; the [pricing FAQ](https://wasabi.com/pricing/faq) calls heavier use "not a good fit" and reserves the right to limit service. A small document shared with thousands of people can cross that line, so watch egress if links travel widely.

## When the AWS SDK alone is enough

`files-sdk/wasabi` is a thin layer over `@aws-sdk/client-s3` and needs the same packages. If this app only ever talks to Wasabi and you're comfortable with the AWS SDK's types and errors, the native client does the signing in a few lines:

```ts title="lib/native.ts" lineNumbers
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

import { contentDisposition } from "@/lib/sharing";

const s3 = new S3Client({
  credentials: {
    accessKeyId: process.env.WASABI_ACCESS_KEY_ID!,
    secretAccessKey: process.env.WASABI_SECRET_ACCESS_KEY!,
  },
  endpoint: "https://s3.eu-central-2.wasabisys.com",
  region: "eu-central-2",
});

export function signedDownloadUrl(key: string, filename: string) {
  return getSignedUrl(
    s3,
    new GetObjectCommand({
      Bucket: "documents",
      Key: key,
      ResponseContentDisposition: contentDisposition(filename),
    }),
    { expiresIn: 60 }
  );
}
```

What the adapter adds on top:

- **One `region` string** sets the endpoint and the signing region, and credentials come from `WASABI_*` variables.
- **Normalized errors.** Failures arrive as a [`FilesError`](/docs/api/errors) with `code` `NotFound`, `Unauthorized`, `Conflict`, `Invalid`, `Unsupported`, or `Provider`, instead of `NoSuchKey`, `AccessDenied`, and `SignatureDoesNotMatch` exceptions you classify yourself.
- **A readable expiry error.** An `expiresIn` over 7 days fails before signing with a message that names the limit.
- **Checksum defaults for non-AWS endpoints.** The adapter sets `requestChecksumCalculation: "WHEN_REQUIRED"` for any custom endpoint, because recent AWS SDKs add CRC32 checksum headers by default and some S3-compatible services reject them. Wasabi doesn't document either way, so with the native client, test an upload on your SDK version.
- **Portability.** The same `storeDocument` and share routes run on `s3()`, `r2()`, or `hetzner()` if you move.

Wasabi features outside the shared API, such as object lock or bucket policies, stay reachable through `files.raw`, which is the adapter's `S3Client`. See [Escape hatch](/docs/escape-hatch).

## Troubleshooting

**`wasabi adapter: missing region.`** `region` was empty. This usually means you read it from an environment variable that isn't set in that environment.

**`wasabi adapter: missing credentials.`** `WASABI_ACCESS_KEY_ID` and `WASABI_SECRET_ACCESS_KEY` aren't set where the server runs, and you didn't pass `accessKeyId` and `secretAccessKey`. The error is thrown when `lib/files.ts` loads, so every route that imports it fails, not only uploads.

**`Wasabi error: presigned URLs must expire within 604800 seconds (7 days), the SigV4 limit; got expiresIn …`** A direct presigned URL can't outlive 7 days. Send a link on your domain instead.

**Every call fails with code `Unauthorized`.** The provider answered `401` or `403`. A wrong secret key produces a `SignatureDoesNotMatch` error, which the adapter maps to `Unauthorized`; against a local MinIO server its message was "The request signature we calculated does not match the signature you provided". Check the secret key first, then that `region` is the bucket's region, since the region is part of what gets signed.

**`NotFound` with the message `The specified key does not exist.`** No object has that key, usually because it was deleted after a share was created. With `wasabi()` pointed at a local MinIO server, `head()` and `download()` of a missing key both failed this way. Check `error.code === "NotFound"` and answer `404`, rather than matching the message: in files-sdk 2.6, `head()` reported the same miss as `UnknownError`, and other adapters word it differently.

**A recipient sees an XML `403` error instead of your page.** They opened a presigned URL rather than your link, for example one copied from their browser's download list. Send them the `/s/…` link again.
