Upload and privately share files on Wasabi with TypeScript
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.
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 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. 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-s33.1148, and Next.js 16.4. The two route handlers use onlyRequestandResponse, so they move to Hono, SvelteKit, orBun.servewithout changes.
npm install files-sdk @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @aws-sdk/s3-presigned-postpnpm add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @aws-sdk/s3-presigned-postyarn add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @aws-sdk/s3-presigned-postbun add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @aws-sdk/s3-presigned-postnub add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @aws-sdk/s3-presigned-postaube add files-sdk @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @aws-sdk/s3-presigned-postfiles-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 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:
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:
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.
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.
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, 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:
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 shows that gateway setup, and the bucket will need a CORS rule 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 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.
// 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:
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.
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: 60only 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 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.responseContentDispositionasks 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 likeRésumé (final).pdfsurvives. Against a local MinIO server, the response carried that header back unchanged.cache-control: no-storestops 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:
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 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 bills an object deleted before its minimum period as Timed Deleted Storage for the remaining days. The 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, 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 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 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:
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
regionstring sets the endpoint and the signing region, and credentials come fromWASABI_*variables. - Normalized errors. Failures arrive as a
FilesErrorwithcodeNotFound,Unauthorized,Conflict,Invalid,Unsupported, orProvider, instead ofNoSuchKey,AccessDenied, andSignatureDoesNotMatchexceptions you classify yourself. - A readable expiry error. An
expiresInover 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
storeDocumentand share routes run ons3(),r2(), orhetzner()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.
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.