Téléversements directs vers S3 avec des URL présignées
Déchargez les téléversements volumineux vers un stockage d’objets en émettant depuis l’API des URL présignées à courte durée de validité.
Téléversements directs vers S3 avec des URL présignées est une leçon NestJS Enterprise Backend APIs gratuite sur CoddyKit. Ceci est la leçon 3 sur 4. Tu peux lire la leçon complète ci-dessous gratuitement — puis la pratiquer en direct dans le navigateur avec un éditeur de code intégré et un tuteur IA 24/7. Elle fait partie du parcours d'apprentissage NestJS Enterprise Backend APIs, et ta progression se synchronise sur le web et l'application CoddyKit. Le cours NestJS Enterprise Backend APIs comprend 4 leçons au total.
Certaines parties de cette leçon n'ont pas encore été traduites et s'affichent en anglais.
Why Not Proxy Uploads Through the API?
When a client uploads a large file, the naive design sends the bytes to your NestJS API, which then forwards them to object storage. This makes your server a bottleneck.
- Memory & CPU pressure — every upload occupies a request thread and buffers/streams through your process.
- Doubled bandwidth — bytes travel client → API → S3, so you pay for the same data twice.
- Request timeouts — load balancers (e.g. ALB, Nginx) cap request duration; multi-GB uploads stall.
The fix: let the browser upload directly to S3. Your API only issues a short-lived, signed URL that grants permission for one specific operation.
What Is a Presigned URL?
A presigned URL is a normal S3 object URL with extra query parameters that encode a temporary, cryptographically-signed grant. Anyone holding the URL can perform exactly one operation (e.g. PutObject) on exactly one key, until it expires.
- Signed with your AWS credentials, but the credentials are never exposed — only the signature is.
- Scoped to a single HTTP method, bucket, and object key.
- Has a hard expiry (seconds), after which S3 rejects it with
403.
Because S3 validates the signature itself, your API does not touch the file bytes at all.
The Upload Flow
The end-to-end flow has three actors: the browser, your NestJS API, and S3.
- 1. Request: Browser asks the API, "I want to upload
avatar.png, 240 KB, image/png." - 2. Sign: API validates the request, generates a unique key, and returns a presigned
PUTURL. - 3. Upload: Browser
PUTs the raw bytes straight to that URL on S3. - 4. Confirm: Browser tells the API the upload succeeded; API persists the key in the database.
Your API stays fast and stateless — it never proxies the payload.
Configuring the S3 Client
Use the AWS SDK v3 modular packages. Create a single S3Client instance and share it via a NestJS provider so credentials and region are configured in one place.
Credentials come from environment variables (or, in production, an IAM role). Never hardcode them.
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { S3Client } from '@aws-sdk/client-s3';
@Injectable()
export class S3Provider {
readonly client: S3Client;
readonly bucket: string;
constructor(private readonly config: ConfigService) {
this.bucket = config.getOrThrow<string>('S3_BUCKET');
this.client = new S3Client({
region: config.getOrThrow<string>('AWS_REGION'),
credentials: {
accessKeyId: config.getOrThrow<string>('AWS_ACCESS_KEY_ID'),
secretAccessKey: config.getOrThrow<string>('AWS_SECRET_ACCESS_KEY'),
},
});
}
}Generating a Presigned PUT URL
The @aws-sdk/s3-request-presigner package signs a command without executing it. You build a PutObjectCommand describing the target key and content type, then call getSignedUrl with an expiresIn.
- ContentType in the command is enforced: the browser must send a matching
Content-Typeheader. - expiresIn is in seconds — keep it short (60–300s) so leaked URLs die quickly.
import { Injectable } from '@nestjs/common';
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { S3Provider } from './s3.provider';
@Injectable()
export class UploadsService {
constructor(private readonly s3: S3Provider) {}
async createUploadUrl(key: string, contentType: string): Promise<string> {
const command = new PutObjectCommand({
Bucket: this.s3.bucket,
Key: key,
ContentType: contentType,
});
return getSignedUrl(this.s3.client, command, { expiresIn: 120 });
}
}Generating Safe, Unique Object Keys
Never trust the client's filename as the S3 key. A user could send ../../etc/passwd or collide with another user's file. Generate the key server-side.
- Namespace by owner:
uploads/{userId}/...so authorization is easy to reason about. - Use a random UUID to guarantee uniqueness.
- Preserve only a sanitized extension for content-type hints and tooling.
import { randomUUID } from 'node:crypto';
import { extname } from 'node:path';
function buildObjectKey(userId: string, originalName: string): string {
const ext = extname(originalName).toLowerCase().replace(/[^.a-z0-9]/g, '');
const safeExt = /^\.[a-z0-9]{1,8}$/.test(ext) ? ext : '';
return `uploads/${userId}/${randomUUID()}${safeExt}`;
}
console.log(buildObjectKey('user-42', 'My Vacation Photo.PNG'));
console.log(buildObjectKey('user-42', 'sneaky/../../etc/passwd'));The Controller Endpoint
Expose a guarded endpoint that takes the file metadata, validates it with a DTO, and returns the presigned URL plus the final key. The client needs the key later to confirm or to build the public/read URL.
Authentication matters here: the signing endpoint is your access-control gate. S3 itself trusts any valid signature, so all checks (who, what size, what type) must happen before you sign.
import { Body, Controller, Post, UseGuards, Req } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { UploadsService } from './uploads.service';
import { CreateUploadDto } from './dto/create-upload.dto';
@UseGuards(JwtAuthGuard)
@Controller('uploads')
export class UploadsController {
constructor(private readonly uploads: UploadsService) {}
@Post('presign')
async presign(@Req() req, @Body() dto: CreateUploadDto) {
return this.uploads.presignForUser(req.user.id, dto);
}
}Validating the Upload Request DTO
Validate metadata before signing. Reject disallowed MIME types and oversized files at the API layer — but remember the client could lie, so this is a first line of defense, not the last.
- Whitelist
contentTypeagainst an allow-list. - Cap the declared
sizeto fail fast on obviously huge uploads.
import { IsIn, IsInt, IsString, Max, Min } from 'class-validator';
const ALLOWED = ['image/png', 'image/jpeg', 'image/webp', 'application/pdf'] as const;
export class CreateUploadDto {
@IsString()
filename: string;
@IsIn(ALLOWED)
contentType: (typeof ALLOWED)[number];
@IsInt()
@Min(1)
@Max(10 * 1024 * 1024) // 10 MB
size: number;
}Enforcing Size with a Signed Content-Length
The DTO size check is advisory — the browser still controls how many bytes it actually PUTs. To make S3 itself reject oversized uploads, bind a content-length range into the signature.
For a single PUT, sign ContentLength so S3 enforces an exact byte count. For more flexible limits (a min/max range), use a presigned POST policy instead, which supports content-length-range conditions.
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
async function presignPost(client, bucket: string, key: string) {
return createPresignedPost(client, {
Bucket: bucket,
Key: key,
Conditions: [
['content-length-range', 1, 10 * 1024 * 1024], // 1 byte – 10 MB
['starts-with', '$Content-Type', 'image/'],
],
Fields: { 'Content-Type': 'image/png' },
Expires: 120,
});
}The Browser-Side Upload
With the presigned PUT URL in hand, the browser uploads with a plain fetch. There is no SDK and no AWS credentials on the client — just the bytes and a matching Content-Type.
- The header must equal the
ContentTypeyou signed, or S3 returns403 SignatureDoesNotMatch. - Do not send
Authorization— the signature lives in the query string.
async function uploadFile(presignedUrl: string, file: File): Promise<void> {
const res = await fetch(presignedUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
});
if (!res.ok) {
throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
}
}Confirming the Upload & Reading Back
Because S3 doesn't notify your API, the client calls back after a successful PUT so you can persist the key. For extra safety, the API can HeadObject to verify the object truly exists and check its real size/type before trusting it.
To serve the file later, either keep the bucket private and issue a presigned GET URL on demand, or (for public assets) store and return the public URL.
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
async confirm(userId: string, key: string) {
const head = await this.s3.client.send(
new HeadObjectCommand({ Bucket: this.s3.bucket, Key: key }),
);
if ((head.ContentLength ?? 0) > 10 * 1024 * 1024) {
throw new BadRequestException('Object exceeds size limit');
}
await this.files.save({ userId, key, size: head.ContentLength });
}
async downloadUrl(key: string) {
return getSignedUrl(
this.s3.client,
new GetObjectCommand({ Bucket: this.s3.bucket, Key: key }),
{ expiresIn: 300 },
);
}Quick Check
Test your understanding of where access control lives in this pattern.
Recap
You learned how to offload heavy uploads to S3 using presigned URLs:
- Why: proxying bytes through the API wastes bandwidth, memory, and hits request timeouts.
- How: the API signs a short-lived
PutObjectCommandwithgetSignedUrl; the browserPUTs straight to S3. - Keys: always generate them server-side (UUID + namespaced by user); never trust client filenames.
- Security: the presign endpoint is the access-control gate — authenticate, whitelist MIME types, and cap size there. Use presigned POST
content-length-rangeto let S3 enforce size. - After upload: confirm with
HeadObject, persist the key, and serve later via presignedGetObjectCommandURLs.
Apprends TypeScript avec un tuteur IA — gratuit
Écris et exécute du vrai code dans ton navigateur, obtiens de l'aide instantanée d'un tuteur IA disponible 24h/24, et reprends là où tu t'es arrêté sur le web ou dans l'app.
- Cours
- 20
- Leçons
- 76
Questions Fréquemment Posées
La leçon « Téléversements directs vers S3 avec des URL présignées » est-elle gratuite ?
Oui — le texte complet de « Téléversements directs vers S3 avec des URL présignées » est gratuit à lire ici sur le web. Pour la pratiquer de manière interactive (un éditeur de code intégré et un tuteur IA 24/7) et déverrouiller le reste du cours NestJS Enterprise Backend APIs, passe à CoddyKit PRO. Le cours NestJS Enterprise Backend APIs comprend 4 leçons au total.
Qu'est-ce que j'apprendrai dans « Téléversements directs vers S3 avec des URL présignées » ?
Déchargez les téléversements volumineux vers un stockage d’objets en émettant depuis l’API des URL présignées à courte durée de validité. Tu pratiques NestJS Enterprise Backend APIs avec du code pratique que tu exécutes directement dans le navigateur, et un tuteur IA 24/7 répond à tes questions au fur et à mesure que tu avances dans la leçon.
Dois-je avoir de l'expérience pour commencer NestJS Enterprise Backend APIs ?
Aucune expérience préalable n'est requise. NestJS Enterprise Backend APIs sur CoddyKit est structuré pour les débutants jusqu'aux apprenants avancés, donc tu peux commencer ici ou depuis le début et avancer à ton rythme. Ceci est la leçon 3 sur 4.
Combien de temps prend la leçon « Téléversements directs vers S3 avec des URL présignées » ?
La plupart des leçons CoddyKit prennent environ 5–10 minutes. Chacune est courte et interactive, tu progresses régulièrement et tu repiques exactement où tu t'es arrêté sur le web et l'app.
Peux-tu écrire et exécuter du code dans cette leçon NestJS Enterprise Backend APIs ?
Oui. Chaque leçon NestJS Enterprise Backend APIs inclut un éditeur de code intégré, tu écris et exécutes du vrai code directement dans ton navigateur et tu reçois des retours IA instantanés — aucune configuration locale requise.
Toutes les leçons de ce cours
- Téléversements multiparties avec les intercepteurs Multer
- Diffuser de grandes réponses avec StreamableFile
- Téléversements directs vers S3 avec des URL présignées
- Pipelines de traitement d’images avec Sharp