Warming up the neural circuits...
By the end of this chapter you will:
There are exactly two patterns. Pick the right one for the use case.
Client ─────[ multipart upload ]────► Backend (validate, scan) ─────► S3
Client ◄────[ binary stream ]────── Backend (read from S3) ◄───── S3Client ─────[ ask for a permission ]────► Backend
Client ◄────[ short-lived signed URL ]── Backend
Client ─────[ PUT bytes directly ]──────► S3 (backend never sees the bytes)
Client ─────[ ask for a download URL ]──► Backend
Client ◄────[ short-lived signed URL ]── Backend
Client ─────[ GET bytes directly ]──────► S3Default to Pattern B. Use Pattern A only when you must inspect bytes synchronously (some compliance flows insist on it). For AV scanning under Pattern B, see "Two-step upload confirmation" below.
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
@Injectable()
export class S3Service {
constructor(private readonly config: ConfigService) {}
private s3
Notes:
expiresIn is short (5–15 minutes for uploads). The URL is a permission slip; long-lived permission slips get pasted into Slack and leaked.ContentType must match what the client sends. S3 will reject the PUT if the client uses a different Content-Type header. The frontend must pass it back exactly.// React/Next/whatever — pseudo-code
async function uploadKyc(file: File) {
// 1. Ask backend for a permission
const { url, key } = await api.post('/kyc/upload-url', {
contentType: file.
Notice that step 3 is essential — without it, your DB has no record of the file. See "Two-step upload" below.
async getDownloadUrl(documentId: string, requesterId: string) {
const doc = await this.documentModel.findByPk(documentId);
if (!doc) throw new NotFoundException();
The authorisation check is the most important line. The signed URL is a bearer token — anyone with it can download — so the only thing standing between an attacker and "every customer's passport" is the if (doc.owner_id !== requesterId) check.
This is the question that motivated this chapter:
"Providers like S3 file links have a session — the link does not work after expiration. How do we handle this?"
The signed URL is valid for the expiresIn window only. After that, S3 returns 403 Forbidden even to the user who legitimately owns the file. Forgetting this leads to two real-world bugs:
A signed URL is valid only for its expiresIn window. After that, S3 returns 403 Forbidden even for the legitimate owner. The three bugs below all stem from forgetting this.
// ❌ NEVER do this
await this.documentModel.update(
{ download_url: signedUrl },
{ where: { id: doc.id } },
);Within 10 minutes that URL is dead. You now have a column full of garbage. Generate the URL at the moment of use, never persist it.
// ❌ NEVER do this
await mail.send({ to: user.email, body: `Download: ${signedUrl}` });The user reads the email two hours later. The link is dead. Worse — anyone who forwards the email can download the file (during the validity window).
The right pattern: send a link to your app (https://app.example.com/documents/{id}/download). When the user clicks it, the backend checks auth and then redirects to a freshly-signed URL.
@Get('documents/:id/download')
async download(@Param('id') id: string, @Req() req, @Res() res) {
const url = await this.s3
// ❌ Loaded once at page mount, used 30 minutes later
const [url] = useState(await api.get(...));
<img src={url} />The page is left open. The user clicks the image. 403.
The right pattern: catch the 403, request a fresh URL, retry once.
async function fetchSigned(documentId: string): Promise<string> {
const { url } = await api.get(`/documents/${documentId}/download-url`);
return url;
}
async
For an SPA with many images on screen, prefer a proxy endpoint instead — /documents/:id/download always works because the backend redirects to a fresh URL each time.
Pattern B uploads bytes directly to S3. The backend never sees the upload. So how does the backend know the file actually arrived?
Client → ask for upload URL
Client → upload to S3
Client → "I'm done" → backend writes a DB rowRisk: the client lies. The DB has a row pointing to an S3 key that doesn't exist.
@Post('kyc/confirm-upload')
async confirm(@Body() body: ConfirmDto, @Req() req) {
// 1. Verify the object exists and is small enough
const head = await this.s3.headObject(body
S3 can fire an event the moment an upload completes:
S3 PutObject → S3 Event Notification → SQS → Worker (validate + write DB row + AV scan)This is fully event-driven. The backend doesn't need a "confirm" endpoint — the event drives the workflow.
A single PUT is fine for files up to a few hundred MB. Beyond that:
S3 multipart upload splits the file into parts (5 MB minimum, up to 10,000 parts) and uploads them independently. If a part fails, only that part retries.
The browser-friendly way:
CreateMultipartUpload → gets an UploadId.UploadPart with partNumber=1, 2, 3....ETag.{partNumber, etag} to backend.CompleteMultipartUpload with that list.The TUS protocol (tus.io) and libraries like Uppy give you resumable uploads on top of S3 multipart with very little code. Use them for "user uploads a 2 GB video" flows.
Signed URLs work per-object. If a user is going to load 50 thumbnails on a page, generating 50 signed URLs is wasteful.
CloudFront signed cookies sign the user's browser session for a CloudFront distribution. The browser can then load any object behind that distribution for the cookie's lifetime, no per-URL signing.
Use this for paginated galleries, document viewers with many images, etc. Use signed URLs for one-off downloads.
getSignedUrl().confirm. Use headObject to check size and existence.A presigned URL is a bearer token — anyone who holds it can read or write that S3 object. Generate at the moment of use. Never store it. Never email it. Always authorise before signing.