Quay lại bài viết
13 thg 5, 2026
16 min read

S3 Presigned URL: Upload và download an toàn mà không cần proxy qua backend

Bạn đang xây một ứng dụng quản lý tài liệu. User upload hợp đồng, hoá đơn (có file 50MB), download bản PDF private, và xem avatar của nhau. Mọi file đều đi qua backend: client gửi file lên server, server stream lên S3, rồi ngược lại khi download. Giờ cao điểm — 200 user upload cùng lúc — backend ngốn 10GB RAM, response time từ 100ms nhảy lên 8 giây, auto-scaling bill nhân ba.

Vấn đề thật sự: tại sao backend lại phải chạm vào từng byte của file? Backend chỉ cần làm đúng một việc — xác thực user và ký một “giấy phép” tạm thời — rồi để client nói chuyện thẳng với S3.

Đó chính là ý tưởng của Presigned URL.

Bài viết này sẽ đi từ khái niệm cơ bản đến kiến trúc thực tế: presigned URL là gì, giải quyết vấn đề gì, và áp dụng vào bài toán thật với 3 luồng — upload file, download tài liệu, và hiển thị ảnh avatar qua CDN.


1. Presigned URL là gì?

1.1. Bài toán cơ bản

Khi bạn tạo một S3 bucket, mặc định nó hoàn toàn private — chỉ có IAM principal (IAM user, IAM role — những thực thể được AWS cấp quyền) mới truy cập được. Browser hay mobile app của user không có AWS credentials, nên không thể gọi thẳng S3 API.

Vậy làm sao để client upload hoặc download file trực tiếp từ S3 mà không cần đi qua backend?

1.2. Ý tưởng

Backend đóng vai là giám đốc, ký tên lên 1 biên bản đặc biệt có tên là presigned URL. Ai có biên bản này này đều có thể xem và lấy tài nguyên trong kho thông qua thủ kho tên là S3 nhưng biên bản chỉ có hiệu lực trong ngày.

Nói đơn giản: presigned URL là một tấm vé vào cửa có thời hạn. Backend ký vé, client dùng vé để vào thẳng S3, không cần đi qua backend nữa.

1.3. Quy trình ký (Signing Process)

Backend sử dụng AWS Signature Version 4 (SigV4) để ký URL. Quy trình ở mức khái niệm:

  1. Backend tập hợp thông tin: HTTP method (GET/POST), bucket name, object key, thời hạn (expires)
  2. Dùng secret access key của IAM role để tạo signature
  3. Ghép signature cùng các metadata vào query string của URL
  4. Trả URL hoàn chỉnh cho client

Toàn bộ quá trình ký diễn ra local trên backend — không gọi S3 API, no round trip time.

1.4. Giải phẫu một presigned URL

Một presigned GET URL trông như thế này:

https://my-bucket.s3.ap-southeast-1.amazonaws.com/documents/user-123/invoice.pdf ?X-Amz-Algorithm=AWS4-HMAC-SHA256 &X-Amz-Credential=AKIA.../20260513/ap-southeast-1/s3/aws4_request &X-Amz-Date=20260513T100000Z &X-Amz-Expires=300 &X-Amz-SignedHeaders=host &X-Amz-Signature=a1b2c3d4e5...
Tham sốÝ nghĩa
X-Amz-AlgorithmThuật toán ký — luôn là AWS4-HMAC-SHA256
X-Amz-CredentialIAM access key + scope (ngày/region/service)
X-Amz-DateThời điểm ký URL
X-Amz-ExpiresThời hạn sống (giây). Ví dụ 300 = 5 phút
X-Amz-SignedHeadersCác HTTP header được bao gồm trong chữ ký
X-Amz-SignatureChữ ký — chuỗi hex được tính từ secret key + request info

1.5. Đặc điểm quan trọng

Lưu ý: Presigned URL kế thừa quyền của IAM entity đã ký nó. Nếu IAM role của backend chỉ có quyền PutObject vào prefix uploads/*, thì presigned URL cũng chỉ có thể upload vào uploads/*.


2. Presigned URL giải quyết vấn đề gì?

2.1. Không cần proxy file qua backend

Ở mô hình truyền thống, mỗi file upload/download đều đi qua backend:

Client → Backend (proxy) → S3 Client ← Backend (proxy) ← S3

Backend phải buffer toàn bộ file trong memory hoặc stream qua, tiêu tốn CPU, RAM, và bandwidth. Khi nhiều user upload đồng thời, backend trở thành bottleneck.

Với presigned URL, backend chỉ ký URL (thao tác CPU-only, vài milliseconds), rồi client nói chuyện thẳng với S3:

Client → Backend (ký URL, ~5ms) → Client Client ←→ S3 (upload/download trực tiếp)

2.2. Bucket vẫn hoàn toàn private

Không cần mở public access, không cần thêm bucket policy phức tạp. S3 Block Public Access vẫn bật toàn bộ 4 options. Presigned URL là cánh cửa duy nhất để vào, và cánh cửa đó có đồng hồ đếm ngược.

2.3. Backend là “gatekeeper”, không phải “courier”

Backend vẫn kiểm soát hoàn toàn:

Nhưng backend không chạm vào file — công việc nặng (truyền tải dữ liệu) để S3 lo.


3. Các use case phổ biến

Use casePhương thứcKhi nào dùngVí dụ
Upload filePresigned POSTUser upload file từ browser/mobileAvatar, hoá đơn, ảnh sản phẩm
Download file privatePresigned GETUser click nút Download để tải fileHợp đồng, báo cáo, file đính kèm
Hiển thị ảnh/assetCloudFront Signed CookiesẢnh hiển thị thường xuyên trong <img> tagAvatar, banner, thumbnail

Tại sao 3 use case lại dùng 3 cơ chế khác nhau?


4. Bài toán thực tế

Để minh hoạ, giả sử bạn đang xây một ứng dụng web có authentication với 3 yêu cầu:

  1. User upload tài liệu (PDF, hợp đồng) và ảnh (avatar, ảnh sản phẩm)
  2. User download tài liệu private — chỉ chủ sở hữu hoặc người được chia sẻ mới tải được
  3. Hiển thị avatar và banner trực tiếp trong <img src="..."> cho mọi user đã đăng nhập

Nguyên tắc thiết kế:

Cấu trúc object key trong S3:

documents/{userId}/{uuid}-{originalFilename} ← tài liệu private images/{userId}/{uuid}-{originalFilename} ← ảnh upload (ảnh sản phẩm, bài viết) avatars/{userId}/{uuid}.{ext} ← avatar (qua CloudFront)

Quy ước có {userId} trong key giúp phân quyền theo prefix, và backend có thể parse userId từ key để double-check với JWT — đó là một lớp defense in depth.


5. Flow 1: Upload file qua Presigned POST

5.1. Tại sao POST mà không phải PUT?

S3 hỗ trợ cả Presigned PUTPresigned POST. Với client untrusted (browser), POST là lựa chọn an toàn hơn vì S3 enforce policy conditions ngay lúc nhận request:

Khía cạnhPresigned PUTPresigned POST
Giới hạn file sizeKhông enforce được tại S3content-length-range — S3 reject nếu vượt
Giới hạn content-typePhải khớp chính xác khi kýstarts-with — cho phép prefix matching (vd: image/)
Giới hạn key pathKey cố định khi kýCho phép prefix linh hoạt
Body formatRaw bytesmultipart/form-data
File > 100MB (multipart upload)Hỗ trợKhông

PUT chỉ phù hợp khi source upload là server-side hoặc cần multipart upload cho file rất lớn. Với browser upload thông thường, luôn dùng POST.

5.2. Sequence diagram

5.3. Backend: Tạo presigned POST

import { S3Client } from '@aws-sdk/client-s3' import { createPresignedPost } from '@aws-sdk/s3-presigned-post' import crypto from 'crypto' import path from 'path' const s3 = new S3Client({ region: process.env.AWS_REGION }) const PURPOSE_CONFIG = { avatar: { prefix: 'avatars', maxSize: 5 * 1024 * 1024, allowedType: 'image/' }, image: { prefix: 'images', maxSize: 10 * 1024 * 1024, allowedType: 'image/' }, document: { prefix: 'documents', maxSize: 20 * 1024 * 1024, allowedType: '' }, } app.post('/api/uploads/presign', authMiddleware, async (req, res) => { const userId = req.user.id const { purpose, fileName, contentType, fileSize } = req.body const config = PURPOSE_CONFIG[purpose] if (!config) { return res.status(400).json({ error: 'Invalid purpose' }) } if (fileSize > config.maxSize) { return res.status(400).json({ error: 'File too large' }) } if (config.allowedType && !contentType.startsWith(config.allowedType)) { return res.status(400).json({ error: 'Invalid content type' }) } const fileId = crypto.randomUUID() const ext = path.extname(fileName) const key = `${config.prefix}/${userId}/${fileId}${ext}` const conditions = [ ['content-length-range', 1, config.maxSize], ['eq', '$key', key], ] if (config.allowedType) { conditions.push(['starts-with', '$Content-Type', config.allowedType]) } const { url, fields } = await createPresignedPost(s3, { Bucket: process.env.S3_BUCKET, Key: key, Conditions: conditions, Fields: { 'Content-Type': contentType }, Expires: 300, }) await db.uploads.create({ fileId, key, userId, purpose, fileName, contentType, fileSize, status: 'pending', createdAt: new Date(), }) res.json({ url, fields, key, fileId }) })

Điểm mấu chốt là mảng conditions:

5.4. Frontend: Upload trực tiếp lên S3

async function uploadFile(file: File, purpose: string) { const presignRes = await fetch('/api/uploads/presign', { method: 'POST', headers: { Authorization: `Bearer ${getToken()}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ purpose, fileName: file.name, contentType: file.type, fileSize: file.size, }), }) if (!presignRes.ok) { throw new Error('Failed to get upload URL') } const { url, fields, key, fileId } = await presignRes.json() const formData = new FormData() Object.entries(fields).forEach(([k, v]) => formData.append(k, v as string)) formData.append('file', file) const uploadRes = await fetch(url, { method: 'POST', body: formData }) if (!uploadRes.ok) { throw new Error('Upload failed') } const confirmRes = await fetch('/api/uploads/confirm', { method: 'POST', headers: { Authorization: `Bearer ${getToken()}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ fileId, key }), }) return await confirmRes.json() }

Lưu ý: formData.append('file', file) phải là field cuối cùng trong FormData. S3 yêu cầu file binary nằm sau tất cả các policy fields.

5.5. Backend: Xác nhận upload (confirm)

Sau khi client báo upload thành công, backend không tin lời client — mà gọi HeadObject để verify:

import { HeadObjectCommand } from '@aws-sdk/client-s3' app.post('/api/uploads/confirm', authMiddleware, async (req, res) => { const { fileId, key } = req.body const upload = await db.uploads.findOne({ fileId, userId: req.user.id }) if (!upload || upload.status !== 'pending') { return res.status(404).json({ error: 'Upload not found' }) } const head = await s3.send( new HeadObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key, }) ) await db.uploads.update( { fileId }, { status: 'ready', actualSize: head.ContentLength, actualContentType: head.ContentType, } ) res.json({ fileId, status: 'ready' }) })

5.6. Xử lý lỗi

Tình huốngS3 trả vềCách xử lý
File quá lớn403 EntityTooLargeClient thông báo lỗi, yêu cầu file nhỏ hơn
Sai content-type403 Policy Condition failedClient kiểm tra lại loại file
URL hết hạn403 Policy expiredClient xin URL mới từ backend
Upload thành công, confirm failLifecycle policy xoá file pending sau 24h
Mất kết nối giữa uploadClient retry toàn bộ flow (xin URL mới)

6. Flow 2: Download tài liệu qua Presigned GET

6.1. Khi nào cần Presigned GET?

Tài liệu private (hợp đồng, báo cáo, file đính kèm) có đặc điểm: truy cập thưa thớtcó chủ đích — user click nút “Download” một cách rõ ràng. Mỗi lần download đều cần kiểm tra quyền và ghi audit log.

Presigned GET URL phù hợp vì:

6.2. Sequence diagram

6.3. Backend: Ký presigned GET URL

import { GetObjectCommand } from '@aws-sdk/client-s3' import { getSignedUrl } from '@aws-sdk/s3-request-presigner' app.get('/api/documents/:id/download', authMiddleware, async (req, res) => { const userId = req.user.id const doc = await db.documents.findById(req.params.id) if (!doc) { return res.status(404).json({ error: 'Document not found' }) } const expiresIn = 300 const url = await getSignedUrl( s3, new GetObjectCommand({ Bucket: process.env.S3_BUCKET, Key: doc.s3Key, ResponseContentDisposition: `attachment; filename="${encodeURIComponent(doc.fileName)}"`, ResponseContentType: doc.contentType, }), { expiresIn } ) await db.auditLogs.create({ userId, action: 'document.download', resourceId: doc.id, createdAt: new Date(), }) res.json({ url, expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(), fileName: doc.fileName, }) })

6.4. Frontend: Download

async function downloadDocument(documentId: string) { const res = await fetch(`/api/documents/${documentId}/download`, { headers: { Authorization: `Bearer ${getToken()}` }, }) if (!res.ok) { throw new Error('Cannot download') } const { url } = await res.json() window.location.href = url }

Vì S3 response có Content-Disposition: attachment, browser sẽ tự động hiển thị hộp thoại download — không cần xử lý thêm ở client.

6.5. Lưu ý bảo mật


7. Flow 3: Hiển thị ảnh qua CloudFront Signed Cookies

7.1. Tại sao không dùng Presigned GET cho ảnh?

Xem lại tình huống: một trang profile hiển thị 50 avatar của user khác nhau. Nếu dùng presigned GET URL cho mỗi ảnh:

  1. 50 API calls trước khi render trang — mỗi avatar cần xin URL riêng
  2. Browser cache bị vô hiệu hoá — mỗi URL có signature khác nhau (vì timestamp khác), browser coi là URL mới → load lại từ đầu
  3. Không CDN-friendly — URL thay đổi liên tục, CDN không cache được

7.2. Giải pháp: CloudFront Signed Cookies

CloudFront Signed Cookies cho phép một bộ 3 cookies cấp quyền truy cập cho toàn bộ URL pattern (ví dụ: https://cdn.app.com/avatars/*). Sau khi browser có cookies:

So sánh với các phương án khác:

Phương ánURL cố địnhBrowser cacheCDN cacheSetup
Presigned URL nhúng vào srcĐơn giản
Backend proxy (<img src="/api/avatar/123">)Đơn giản
Backend redirect (302 → presigned URL)Trung bình
CloudFront Signed CookiesPhức tạp

7.3. Kiến trúc: CloudFront + OAC + S3

Origin Access Control (OAC) là cơ chế mới của CloudFront (thay thế OAI cũ), đảm bảo chỉ CloudFront mới đọc được S3 — user không thể bypass CloudFront để truy cập S3 trực tiếp.

Setup bao gồm:

  1. Tạo CloudFront Key Pair (RSA 2048-bit) — public key upload lên CloudFront, private key lưu trong Secrets Manager
  2. Tạo Trusted Key Group chứa public key
  3. Tạo CloudFront Distribution với OAC pointing tới S3 bucket
  4. Cập nhật S3 bucket policy chỉ cho phép CloudFront OAC
  5. Cấu hình DNS: cdn.app.com → CNAME → CloudFront domain

S3 bucket policy cho OAC:

{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "cloudfront.amazonaws.com" }, "Action": "s3:GetObject", "Resource": "arn:aws:s3:::my-bucket/*", "Condition": { "StringEquals": { "AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID" } } } ] }

7.4. Sequence diagram

7.5. Backend: Tạo Signed Cookies

Gọi endpoint này ngay sau khi login thành công:

import { getSignedCookies } from '@aws-sdk/cloudfront-signer' import fs from 'fs' const PRIVATE_KEY = fs.readFileSync(process.env.CLOUDFRONT_PRIVATE_KEY_PATH, 'utf-8') const KEY_PAIR_ID = process.env.CLOUDFRONT_KEY_PAIR_ID const CDN_DOMAIN = 'cdn.app.com' app.post('/api/auth/cdn-cookies', authMiddleware, async (req, res) => { const expiresIn = 60 * 60 const expiresAt = Math.floor(Date.now() / 1000) + expiresIn const policy = { Statement: [ { Resource: `https://${CDN_DOMAIN}/*`, Condition: { DateLessThan: { 'AWS:EpochTime': expiresAt }, }, }, ], } const cookies = getSignedCookies({ keyPairId: KEY_PAIR_ID, privateKey: PRIVATE_KEY, policy: JSON.stringify(policy), }) const cookieOptions = { domain: '.app.com', httpOnly: true, secure: true, sameSite: 'Lax' as const, maxAge: expiresIn * 1000, } res.cookie('CloudFront-Policy', cookies['CloudFront-Policy'], cookieOptions) res.cookie('CloudFront-Signature', cookies['CloudFront-Signature'], cookieOptions) res.cookie('CloudFront-Key-Pair-Id', cookies['CloudFront-Key-Pair-Id'], cookieOptions) res.json({ expiresAt: new Date(expiresAt * 1000).toISOString() }) })

Lưu ý cookie domain: .app.com (có dấu chấm ở đầu) cho phép cookie được gửi tới cả app.com (frontend) và cdn.app.com (CloudFront). Đây là lý do frontend và CDN cần cùng parent domain.

7.6. Frontend: Không cần làm gì đặc biệt

Sau khi gọi API set cookies, browser tự động gửi cookies kèm mọi request tới cdn.app.com:

async function setupCdnAccess() { await fetch('/api/auth/cdn-cookies', { method: 'POST', headers: { Authorization: `Bearer ${getToken()}` }, credentials: 'include', }) } function Avatar({ userId, fileId }: { userId: string; fileId: string }) { return <img src={`https://cdn.app.com/avatars/${userId}/${fileId}.jpg`} alt="Avatar" loading="lazy" /> }

URL cố định, browser cache hoạt động bình thường, CDN cache hoạt động — không có gì magic phía client.

Cookie chỉ sống 1 giờ. Có 3 chiến lược refresh:

Chiến lượcCách hoạt độngƯu/Nhược
ProactiveLưu expiresAt, set timer gọi lại trước khi hết hạn 5 phútUX mượt, nhưng phức tạp (xử lý tab inactive)
ReactiveKhi <img> fail (403), trigger refresh + reload ảnhĐơn giản, nhưng user thấy broken image trong 1-2s
Hybrid (khuyến nghị)Refresh khi app khởi động + mỗi khi focus lại tab. Kết hợp onError fallbackCân bằng giữa UX và độ phức tạp

Khi logout, phải clear cookies:

app.post('/api/auth/logout', (req, res) => { const cookieOptions = { domain: '.app.com', path: '/' } res.clearCookie('CloudFront-Policy', cookieOptions) res.clearCookie('CloudFront-Signature', cookieOptions) res.clearCookie('CloudFront-Key-Pair-Id', cookieOptions) res.json({ ok: true }) })

Lưu ý: Ngay cả khi clear cookie, cookie cũ vẫn hợp lệ tới expire time — CloudFront verify dựa trên signature, không có khái niệm “revoke”. Đây là trade-off của signed cookie/URL. Nếu cần revoke ngay lập tức, phải rotate private key (ảnh hưởng tất cả user) hoặc giữ thời hạn cookie rất ngắn (15-30 phút).

7.8. So sánh Presigned URL vs Signed Cookies

Tiêu chíPresigned URLSigned Cookies
Phạm vi1 URL = 1 object1 bộ cookie = toàn bộ path pattern
Browser cacheKhông (URL khác mỗi lần)Có (URL cố định)
Overhead per requestKý mỗi URL riêngKý 1 lần, dùng cho mọi request
Phù hợp choDownload file, truy cập thưaHiển thị ảnh, truy cập thường xuyên
Cần CDN?Không bắt buộcCần CloudFront
Quản lý cookieKhôngCần set/clear/refresh cookies

8. Bảo mật tổng hợp

8.1. Defense in depth — nhìn qua 3 flows

Lớp bảo vệUpload (POST)Download (GET)Images (Cookies)
AuthenticationJWT verifyJWT verifyJWT verify
AuthorizationOwnership checkOwnership + shared accessRole-based (đã login)
S3 levelPolicy conditions (size, type, key)OAC (chỉ CloudFront đọc S3)
Credential lifetime5 phút5 phút1 giờ
Key naming{prefix}/{userId}/{uuid}Verify userId trong keyPath pattern /avatars/*
VerificationHeadObject confirmAudit log
NetworkHTTPS bắt buộcHTTPS bắt buộcHTTPS + HttpOnly cookies

8.2. Threat model

ThreatMitigation
Leak presigned URLTTL ngắn (5 phút), không log full URL, HTTPS only
Leak CloudFront cookieHttpOnly (JS không đọc được), Secure, SameSite, TTL 1h
Upload file maliciousContent-type validation ở backend + S3 policy. Tuỳ chọn: virus scan async qua S3 event → Lambda
Upload vượt giới hạn sizecontent-length-range trong POST policy — S3 reject trước khi lưu
User A truy cập file User BOwnership check + key namespace + path-based authorization
Replay attack sau logoutChấp nhận window risk (cookie/URL vẫn hợp lệ tới expire). Giữ TTL ngắn
Bucket vô tình thành publicBlock Public Access ON + CloudWatch alarm khi config thay đổi

9. Tổng kết

Kiến trúc 3 luồng sử dụng S3 Presigned URL và CloudFront Signed Cookies giải quyết bài toán upload/download/display tài nguyên mà không cần proxy file qua backend. Những điểm cần nhớ:

  1. Luôn dùng Presigned POST (không phải PUT) cho upload từ browser — policy conditions là lưới an toàn cuối cùng mà S3 enforce
  2. Presigned GET cho download có chủ đích — mỗi lần cần audit, check quyền, rồi ký URL ngắn hạn
  3. CloudFront Signed Cookies cho ảnh hiển thị thường xuyên — tiết kiệm chi phí ký, giữ browser cache hoạt động, và tận dụng CDN
  4. Giữ credential lifetime ngắn: 5 phút cho URL, 1 giờ cho cookies. Ngắn hơn = ít rủi ro hơn khi bị leak
  5. Backend là gatekeeper, không phải courier — nó validate và ký, nhưng không bao giờ chạm vào file. Công việc nặng để S3 và CloudFront lo

Liên quan