Presigned URLs
A presigned URL is a temporary, signed URL that grants access to a private S3 object for a limited time — without making the object public. They are the standard way to serve private files from S3.
Why Presigned URLs?
When your S3 bucket is private (as it should be), files are not accessible via their plain S3 URL. Instead:
- Your server generates a presigned URL with an expiry (e.g., 15 minutes)
- The client uses that URL to download or upload the file directly with S3
- After expiry, the URL is invalid
This keeps your bucket private while still letting users access their files.
Install
- npm
- Yarn
- pnpm
- Bun
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
yarn add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
bun add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
Presigned URL for Downloading (GET)
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import s3 from "../config/s3.js";
export async function getPresignedDownloadUrl(key, expiresInSeconds = 900) {
const command = new GetObjectCommand({
Bucket: process.env.AWS_S3_BUCKET_NAME,
Key: key,
});
return getSignedUrl(s3, command, { expiresIn: expiresInSeconds });
}
Route to get a download link
import { getPresignedDownloadUrl } from "../utils/getPresignedUrl.js";
router.get("/:key/url", async (req, res) => {
try {
const url = await getPresignedDownloadUrl(req.params.key);
res.json({ url, expiresIn: "15 minutes" });
} catch (error) {
res.status(500).json({ message: "Could not generate URL", error: error.message });
}
});
The client can then use this URL directly in an <img> tag, <a href>, or a fetch() call to download the file.
Presigned URL for Uploading (PUT)
You can also let clients upload files directly to S3 — completely bypassing your server. This is useful for large files because the data never passes through your Express app.
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import s3 from "../config/s3.js";
import path from "path";
export async function getPresignedUploadUrl(originalName, mimetype, expiresInSeconds = 300) {
const ext = path.extname(originalName).toLowerCase();
const baseName = path.basename(originalName, ext).toLowerCase().replace(/\s+/g, "-");
const key = `uploads/${baseName}-${Date.now()}${ext}`;
const command = new PutObjectCommand({
Bucket: process.env.AWS_S3_BUCKET_NAME,
Key: key,
ContentType: mimetype,
});
const url = await getSignedUrl(s3, command, { expiresIn: expiresInSeconds });
return { url, key };
}
Route to generate an upload URL
import { getPresignedUploadUrl } from "../utils/getUploadPresignedUrl.js";
router.post("/upload-url", async (req, res) => {
const { fileName, mimetype } = req.body;
if (!fileName || !mimetype) {
return res.status(400).json({ message: "fileName and mimetype are required" });
}
try {
const { url, key } = await getPresignedUploadUrl(fileName, mimetype);
res.json({ uploadUrl: url, key, expiresIn: "5 minutes" });
} catch (error) {
res.status(500).json({ message: "Could not generate upload URL", error: error.message });
}
});
How the client uses the upload URL
The frontend makes a PUT request directly to S3 using the presigned URL:
async function uploadFile(file) {
// Step 1: Get presigned upload URL from your Express server
const { uploadUrl, key } = await fetch("/files/upload-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileName: file.name, mimetype: file.type }),
}).then((r) => r.json());
// Step 2: PUT the file directly to S3 using the presigned URL
await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
// Step 3: Save the key to your backend so you can reference it later
await fetch("/files", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, name: file.name }),
});
}
For large files (videos, high-res images), routing the upload through Express wastes server bandwidth and memory. With a presigned upload URL, the file goes directly from the browser to S3. Your server only handles the metadata.
Complete Route File
import express from "express";
import { getPresignedDownloadUrl } from "../utils/getPresignedUrl.js";
import { getPresignedUploadUrl } from "../utils/getUploadPresignedUrl.js";
const router = express.Router();
// Generate a download URL for an existing file
router.get("/:key/url", async (req, res) => {
try {
const url = await getPresignedDownloadUrl(decodeURIComponent(req.params.key));
res.json({ url, expiresIn: "15 minutes" });
} catch (error) {
res.status(500).json({ message: "Could not generate URL" });
}
});
// Generate an upload URL so the client can upload directly to S3
router.post("/upload-url", async (req, res) => {
const { fileName, mimetype } = req.body;
if (!fileName || !mimetype) {
return res.status(400).json({ message: "fileName and mimetype are required" });
}
try {
const { url, key } = await getPresignedUploadUrl(fileName, mimetype);
res.json({ uploadUrl: url, key });
} catch (error) {
res.status(500).json({ message: "Could not generate upload URL" });
}
});
export default router;
Expiry Guidance
| Use Case | Recommended Expiry |
|---|---|
| Viewing an image/document | 15 minutes |
| Streaming a video | 1–2 hours |
| Sending a download link by email | 24–48 hours |
| Client-side upload | 5 minutes |
Key Takeaways
- Presigned URLs allow temporary, secure access to private S3 objects
- GET presigned URLs let clients download private files
- PUT presigned URLs let clients upload directly to S3, bypassing your server
- Always store the S3
keyin your database, not the presigned URL (it expires) - Set expiry times as short as the use case allows