Cloud
Presigned S3 Transfers
Give browsers direct access to a private S3 bucket using time-limited signed URLs — no credentials, bucket details, or account info ever reaches the client.
TLDR;When a user uploads a file through a web form, the default path sends every byte through your server: browser → your PHP endpoint → S3. A 20 MB upload passes through your infrastructure twice, consuming ingress bandwidth, PHP memory, and CPU. The file lands in S3 but your server paid the transit cost. With a private bucket you also need to hold AWS credentials server-side and keep them out of every client-facing response. Presigned URLs eliminate the detour entirely.
The core idea is delegation. Your server does not move the file — it issues a time-limited authorization that lets the browser communicate with S3 directly. AWS validates the authorization, the file travels on S3’s own edge network, and your server never touches the bytes. The bucket stays completely private. Your AWS credentials never leave your server.
What a Presigned URL Is
A presigned URL is a standard HTTPS URL with authentication baked into the query string. The signature is an HMAC computed from your AWS secret key, the target operation, the resource path, and an expiry timestamp. When the request arrives at S3, AWS re-derives the signature and compares — if it matches and the URL has not expired, the operation is allowed. If it has expired, or if any parameter was altered, the request is rejected with a 403.
The client sees the URL. What the client does not see: your AWS access key, your secret, your account ID, your bucket name as a configuration value, or any other infrastructure detail. A presigned URL is self-contained proof that an authorized server approved a specific operation on a specific resource during a specific time window.
The Two Operations
Presigned POST — Uploads
S3’s presigned POST is not a URL alone — it is a URL plus a set of signed policy fields. The policy encodes everything the upload is allowed to do: which bucket, which key prefix, which content types are permitted, what the maximum file size is, and when the authorization expires. AWS signs the entire policy with your secret key. The browser assembles a standard HTML multipart form, appends the signed fields and the file, and POSTs directly to S3. Any deviation — wrong content type, file too large, key outside the allowed prefix, expired window — is rejected before the object is stored.
Presigned GET — Downloads
A presigned GET is a single time-limited URL. The browser navigates to it, opens it as
a link, or receives it as a redirect. S3 validates the signature, checks the expiry,
and serves the object body directly. No form fields, no policy document, no multi-step
process — just a URL that works for a defined window and then becomes useless. The
ResponseContentDisposition parameter can force a browser download dialog
with your chosen filename instead of rendering the file inline.
Why This Architecture
- Zero credential exposure. Your AWS access key and secret never appear in HTML, JavaScript, JSON responses, browser storage, or developer-tools network traffic. The bucket name is embedded in the S3 endpoint hostname but reveals nothing about your account structure or IAM configuration.
- No server bandwidth for file data. Files travel directly between the browser and S3’s distributed edge. A 50 MB upload costs your server zero bytes of egress. On cloud instances or VPS plans priced by bandwidth, this is a real operating-cost difference at scale.
- No server memory pressure. PHP never buffers the file body.
Large uploads cannot crash a PHP process by exhausting
memory_limit, and there is noupload_max_filesizetuning required for the presigned-URL endpoint itself. - S3 absorbs all file traffic. Your server handles only lightweight JSON requests for URL generation — one small round trip regardless of file size or concurrent upload count. File throughput scales with S3, not your instance.
- Time-limited by design. A presigned URL leaked after expiry is worthless. Short windows (15–20 minutes for uploads, 5–15 minutes for downloads) sharply limit the damage from any interception.
- Policy enforcement at the S3 edge. Presigned POST conditions — max file size, content-type allowlist, key prefix restriction — are enforced by S3’s own signature verification, not by your application code. A client that manipulates a form field fails S3’s check automatically.
Upload Data Flow
Browser Your PHP Server AWS S3
│ │ │
│ POST /upload-url │ │
│ { filename, type, size } │ │
├────────────────────────────►│ │
│ │ check session │
│ │ validate MIME + size │
│ │ build s3_key (UUID) │
│ │ sign POST policy │
│◄────────────────────────────┤ │
│ { url, fields{}, key } │ │
│ │ │
│ POST multipart/form-data to S3 │
│ ← policy fields first, file last → │
├───────────────────────────────────────────────────────►│
│ │ verify signature
│ │ enforce policy
│◄───────────────────────────────────────────────────────┤
│ 204 No Content │
│ │ │
│ POST /confirm-upload │ │
│ { key, filename, size } │ │
├────────────────────────────►│ │
│ │ validate key prefix │
│ │ INSERT uploads row │
│◄────────────────────────────┤ │
│ { file_id: 42 } │ │
The confirm step is necessary because your server never receives the S3 success response
— the browser does. Without it, your database has no record of the file. The browser
reports the completed key, your server validates the prefix (confirming it was issued to
the current user), and records the row. The client stores the returned file_id
to reference the file in future download requests.
Download Data Flow
Browser Your PHP Server AWS S3
│ │ │
│ GET /download-url │ │
│ ?file_id=42 │ │
├────────────────────────────►│ │
│ │ check session │
│ │ SELECT s3_key │
│ │ WHERE id = 42 │
│ │ AND member_id = self │
│ │ sign GET URL (+15 min) │
│◄────────────────────────────┤ │
│ { url: "https://..." } │ │
│ │ │
│ GET presigned URL (direct to S3) │
├───────────────────────────────────────────────────────►│
│ │ verify signature
│ │ check expiry
│◄───────────────────────────────────────────────────────┤
│ file bytes + forced download header │
The client passes a file_id integer, never a raw S3 key. The server looks up
the S3 key and verifies the requesting session owns that file before signing. The bucket
structure — uploads/42/a1b2c3d4_report.pdf — is never exposed
to the client.
AWS Account Setup
Step 1 — Create the S3 Bucket
Log into the AWS Management Console, go to S3, and click Create bucket.
- Choose a unique name (globally unique across all AWS accounts).
- Select the AWS region closest to your server to minimize transfer latency.
- Leave all other settings at their defaults.
- Click Create bucket.
Step 2 — Confirm Block Public Access
Open the bucket → Permissions tab → Block public access (bucket settings). All four toggles must be ON. This is the AWS default for new buckets — confirm it has not been changed. Nothing in this architecture requires any public access.
Step 3 — Create an IAM Policy and User
Go to IAM → Policies → Create policy. Switch to the JSON
editor and paste the following, replacing YOUR-BUCKET-NAME:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowUpload",
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/uploads/*"
},
{
"Sid": "AllowDownload",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/*"
},
{
"Sid": "AllowList",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::YOUR-BUCKET-NAME"
},
{
"Sid": "AllowDelete",
"Effect": "Allow",
"Action": "s3:DeleteObject",
"Resource": "arn:aws:s3:::YOUR-BUCKET-NAME/uploads/*"
}
]
}
Name the policy (e.g., app-s3-transfers) and save it. Then:
- Go to IAM → Users → Create user. Name it
app-s3-user. No console access is needed. - On the permissions step, choose Attach policies directly and attach the policy you just created.
- After creating the user, open it → Security credentials → Create access key. Select Application running outside AWS.
- Copy both the Access Key ID and the Secret Access Key. The secret is shown only once.
This policy grantss3:PutObject,s3:GetObject,s3:ListBucket, ands3:DeleteObject. Note thats3:ListBuckettargets the bucket ARN without a trailing/*— it is a bucket-level action, not an object action.s3:DeleteObjectis scoped touploads/*, matching the upload prefix. The user cannot delete the bucket itself, change permissions, or touch any other AWS service.
Step 4 — Configure CORS on the Bucket
Direct browser uploads require a CORS policy on the bucket. Without it, the browser blocks the cross-origin POST before the request leaves the client. Open the bucket → Permissions → Cross-origin resource sharing (CORS) → Edit:
[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["POST", "GET"],
"AllowedOrigins": [
"https://your-domain.com",
"http://localhost:8080"
],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]
Replace https://your-domain.com with your production origin. The
localhost entry is for local development only — remove it before
shipping. Never use "*" as the origin in production; that would allow
any website to trigger uploads to your bucket using your presigned fields.
Step 5 — Store Credentials Server-Side
Add the four values to nonserve/.env — the directory mounted
outside the document root and never served by Apache:
AWS_KEY=AKIAxxxxxxxxxxxxxxxxxxxx
AWS_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AWS_REGION=us-east-1
S3_BUCKET=your-bucket-name
Update .env.example with empty placeholder keys so the next
developer knows what values are required.
PHP 8.3 Implementation
Step 1 — Install the AWS SDK
docker compose exec web composer require aws/aws-sdk-php
This adds the SDK to composer.json and installs it into
vendor/. The SDK’s autoloader is wired through Composer —
require_once __DIR__ . '/vendor/autoload.php' covers everything.
No additional PHP extensions are needed beyond what is already in the Dockerfile.
Step 2 — Create the Uploads Table
Your server needs a record of each upload so it can later authorize download
requests by file_id:
CREATE TABLE uploads (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
member_id INT UNSIGNED NOT NULL,
s3_key VARCHAR(512) NOT NULL,
filename VARCHAR(255) NOT NULL,
mime_type VARCHAR(100) NOT NULL,
file_size BIGINT UNSIGNED NOT NULL DEFAULT 0,
uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_member (member_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Step 3 — upload-url.php
This endpoint receives file metadata from the browser, validates it, generates a unique S3 key, and returns the signed form fields. It never touches the file bytes.
<?php
session_start(['cookie_httponly' => true, 'cookie_secure' => true, 'cookie_samesite' => 'Lax']);
header('Content-Type: application/json');
if (empty($_SESSION['signed_in'])) {
http_response_code(401);
exit(json_encode(['error' => 'Unauthorized']));
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405); exit();
}
require_once __DIR__ . '/vendor/autoload.php';
(Dotenv\Dotenv::createImmutable(__DIR__ . '/../nonserve/'))->load();
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$filename = (string) ($input['filename'] ?? '');
$mime_type = (string) ($input['mime_type'] ?? '');
$file_size = (int) ($input['file_size'] ?? 0);
$allowed_types = ['application/pdf', 'image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!in_array($mime_type, $allowed_types, true)) {
http_response_code(422);
exit(json_encode(['error' => 'File type not permitted']));
}
$max_bytes = 20 * 1024 * 1024; // 20 MB
if ($file_size < 1 || $file_size > $max_bytes) {
http_response_code(422);
exit(json_encode(['error' => 'File size out of range']));
}
$safe_name = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($filename));
$s3_key = 'uploads/' . (int) $_SESSION['member_id'] . '/'
. bin2hex(random_bytes(8)) . '_' . $safe_name;
use Aws\S3\S3Client;
use Aws\S3\PostObjectV4;
$s3 = new S3Client([
'version' => 'latest',
'region' => $_ENV['AWS_REGION'],
'credentials' => ['key' => $_ENV['AWS_KEY'], 'secret' => $_ENV['AWS_SECRET']],
]);
$conditions = [
['bucket' => $_ENV['S3_BUCKET']],
['acl' => 'private'],
['eq', '$key', $s3_key],
['eq', '$Content-Type', $mime_type],
['content-length-range', 1, $max_bytes],
];
$post = new PostObjectV4(
$s3,
$_ENV['S3_BUCKET'],
['acl' => 'private', 'key' => $s3_key, 'Content-Type' => $mime_type],
$conditions,
'+20 minutes'
);
echo json_encode([
'url' => $post->getFormAttributes()['action'],
'fields' => $post->getFormInputs(),
'key' => $s3_key,
]);
Step 4 — confirm-upload.php
Called by the browser immediately after S3 returns 204. Validates that the reported key belongs to the current user’s prefix, then records the upload in the database. Without this call, the file exists in S3 but your application has no knowledge of it.
<?php
session_start(['cookie_httponly' => true, 'cookie_secure' => true, 'cookie_samesite' => 'Lax']);
header('Content-Type: application/json');
if (empty($_SESSION['signed_in'])) { http_response_code(401); exit(); }
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit(); }
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$s3_key = (string) ($input['key'] ?? '');
$filename = (string) ($input['filename'] ?? '');
$mime = (string) ($input['mime_type'] ?? '');
$size = (int) ($input['file_size'] ?? 0);
$prefix = 'uploads/' . (int) $_SESSION['member_id'] . '/';
if (!str_starts_with($s3_key, $prefix)) {
http_response_code(403);
exit(json_encode(['error' => 'Forbidden']));
}
include __DIR__ . '/includes/dbaccess.php';
$db = new dbaccess();
$file_id = $db->insert_prepared(
'INSERT INTO uploads (member_id, s3_key, filename, mime_type, file_size) VALUES (?, ?, ?, ?, ?)',
'isssi',
(int) $_SESSION['member_id'], $s3_key, basename($filename), $mime, $size
);
echo json_encode(['file_id' => $file_id]);
Step 5 — download-url.php
Looks up the S3 key by file_id, verifies the requesting session owns
that record, and returns a presigned GET URL. The
ResponseContentDisposition parameter forces a download dialog using
the original filename rather than rendering inline.
<?php
session_start(['cookie_httponly' => true, 'cookie_secure' => true, 'cookie_samesite' => 'Lax']);
header('Content-Type: application/json');
if (empty($_SESSION['signed_in'])) { http_response_code(401); exit(); }
if ($_SERVER['REQUEST_METHOD'] !== 'GET') { http_response_code(405); exit(); }
require_once __DIR__ . '/vendor/autoload.php';
(Dotenv\Dotenv::createImmutable(__DIR__ . '/../nonserve/'))->load();
$file_id = filter_input(INPUT_GET, 'file_id', FILTER_VALIDATE_INT);
$member_id = (int) $_SESSION['member_id'];
if (!$file_id) {
http_response_code(400);
exit(json_encode(['error' => 'Invalid file ID']));
}
include __DIR__ . '/includes/dbaccess.php';
$db = new dbaccess();
$stmt = $db->prepare('SELECT s3_key, filename FROM uploads WHERE id = ? AND member_id = ?');
mysqli_stmt_bind_param($stmt, 'ii', $file_id, $member_id);
$db->execute($stmt);
$row = $db->fetch_next_row($db->get_result($stmt));
$db->stmt_close($stmt);
if (!$row) {
http_response_code(404);
exit(json_encode(['error' => 'File not found']));
}
use Aws\S3\S3Client;
$s3 = new S3Client([
'version' => 'latest',
'region' => $_ENV['AWS_REGION'],
'credentials' => ['key' => $_ENV['AWS_KEY'], 'secret' => $_ENV['AWS_SECRET']],
]);
$cmd = $s3->getCommand('GetObject', [
'Bucket' => $_ENV['S3_BUCKET'],
'Key' => $row['s3_key'],
'ResponseContentDisposition' => 'attachment; filename="'
. rawurlencode($row['filename']) . '"',
]);
$url = (string) $s3->createPresignedRequest($cmd, '+15 minutes')->getUri();
echo json_encode(['url' => $url]);
Step 6 — Frontend JavaScript
These two functions handle the complete client-side flow. Wire them to your file input and download buttons.
// Upload a File object directly to S3, confirm with server, return file_id
async function uploadToS3(file) {
// Step 1 — get signed form fields from our server
const res = await fetch('/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: file.name,
mime_type: file.type,
file_size: file.size,
}),
});
if (!res.ok) throw new Error('Could not get upload URL');
const { url, fields, key } = await res.json();
// Step 2 — POST multipart form directly to S3; file field MUST be last
const form = new FormData();
Object.entries(fields).forEach(([k, v]) => form.append(k, v));
form.append('file', file);
const s3res = await fetch(url, { method: 'POST', body: form });
if (s3res.status !== 204) throw new Error('S3 upload rejected');
// Step 3 — tell our server the upload succeeded so it records the row
const confirm = await fetch('/confirm-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key,
filename: file.name,
mime_type: file.type,
file_size: file.size,
}),
});
const { file_id } = await confirm.json();
return file_id;
}
// Trigger a forced browser download for a previously uploaded file
async function downloadFile(fileId) {
const res = await fetch(`/download-url?file_id=${fileId}`);
if (!res.ok) throw new Error('Could not get download URL');
const { url } = await res.json();
window.location.href = url; // browser navigates to S3, save dialog appears
}
Thefilefield in the FormData must be appended last. S3 reads the multipart stream sequentially — it validates the policy fields before processing the file body. Any field appended afterfileis silently discarded. Browsers do not enforce append order; your code must.
Bare-Bones Page Examples
Three minimal pages showing how the endpoints and functions from Steps 3–6 wire into real pages. Session guard, the relevant markup or query, and the function call — nothing else. Use these as the shell to wrap around your own layout.
upload.php — pick a file and upload it
Guards the session, renders a file picker and button, then on click calls
uploadToS3() (the function from Step 6, inlined here). On success the
returned file_id is ready to store or pass to the next step.
<?php
session_start(['cookie_httponly' => true, 'cookie_secure' => true, 'cookie_samesite' => 'Lax']);
if (empty($_SESSION['signed_in'])) { header('Location: /sign-in.php'); exit(); }
$page_title = 'Upload';
include 'includes/header.php';
?>
<h1>Upload a file</h1>
<input type="file" id="pick" accept=".pdf,.jpg,.jpeg,.png,.gif,.webp">
<button id="go">Upload</button>
<p id="msg"></p>
<script nonce="<?= $csp_nonce ?>">
document.getElementById('go').onclick = async () => {
const file = document.getElementById('pick').files[0];
if (!file) return;
document.getElementById('msg').textContent = 'Uploading…';
try {
const fileId = await uploadToS3(file);
document.getElementById('msg').textContent = 'Done — file_id ' + fileId;
} catch (e) {
document.getElementById('msg').textContent = 'Error: ' + e.message;
}
};
async function uploadToS3(file) {
const res = await fetch('/upload-url.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, mime_type: file.type, file_size: file.size }),
});
if (!res.ok) throw new Error('Could not get upload URL');
const { url, fields, key } = await res.json();
const form = new FormData();
Object.entries(fields).forEach(([k, v]) => form.append(k, v));
form.append('file', file);
const s3res = await fetch(url, { method: 'POST', body: form });
if (s3res.status !== 204) throw new Error('S3 upload rejected');
const ack = await fetch('/confirm-upload.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, filename: file.name, mime_type: file.type, file_size: file.size }),
});
const { file_id } = await ack.json();
return file_id;
}
</script>
<?php include 'includes/footer.php'; ?>
files.php — list files and download on click
Queries the uploads table for the current member, renders one row per file with a
download button, and on click calls downloadFile() (the function from
Step 6, inlined here). The function fetches the presigned URL server-side and
hands it to window.location.href — the browser save dialog
appears without any bytes passing through your server.
<?php
session_start(['cookie_httponly' => true, 'cookie_secure' => true, 'cookie_samesite' => 'Lax']);
if (empty($_SESSION['signed_in'])) { header('Location: /sign-in.php'); exit(); }
include 'includes/dbaccess.php';
$db = new dbaccess();
$stmt = $db->prepare('SELECT id, filename FROM uploads WHERE member_id = ? ORDER BY id DESC');
mysqli_stmt_bind_param($stmt, 'i', $_SESSION['member_id']);
$db->execute($stmt);
$result = $db->get_result($stmt);
$rows = [];
while ($row = $db->fetch_next_row($result)) { $rows[] = $row; }
$db->stmt_close($stmt);
$page_title = 'My Files';
include 'includes/header.php';
?>
<h1>My Files</h1>
<?php foreach ($rows as $row): ?>
<p>
<?= htmlspecialchars($row['filename']) ?>
<button onclick="downloadFile(<?= (int)$row['id'] ?>)">Download</button>
</p>
<?php endforeach; ?>
<script nonce="<?= $csp_nonce ?>">
async function downloadFile(fileId) {
const res = await fetch('/download-url.php?file_id=' + fileId);
if (!res.ok) throw new Error('Could not get download URL');
const { url } = await res.json();
window.location.href = url;
}
</script>
<?php include 'includes/footer.php'; ?>
confirm-upload.php — verification callback
The browser POSTs here immediately after S3 returns 204 — this is the third
call in the upload sequence. It is a JSON-only endpoint with no HTML output. It
validates that the reported key starts with the current user’s prefix (so one
user cannot claim another’s S3 object), writes the database row, and returns
the file_id that uploadToS3() passes back to the caller.
The full implementation is in Step 4 above.
<?php
session_start(['cookie_httponly' => true, 'cookie_secure' => true, 'cookie_samesite' => 'Lax']);
header('Content-Type: application/json');
if (empty($_SESSION['signed_in'])) { http_response_code(401); exit(); }
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit(); }
// No HTML — this endpoint is called by uploadToS3() after S3 returns 204
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$s3_key = (string) ($input['key'] ?? '');
$filename = (string) ($input['filename'] ?? '');
$mime = (string) ($input['mime_type'] ?? '');
$size = (int) ($input['file_size'] ?? 0);
// Reject any key that does not belong to this user's upload prefix
$prefix = 'uploads/' . (int) $_SESSION['member_id'] . '/';
if (!str_starts_with($s3_key, $prefix)) {
http_response_code(403);
exit(json_encode(['error' => 'Forbidden']));
}
include __DIR__ . '/includes/dbaccess.php';
$db = new dbaccess();
$file_id = $db->insert_prepared(
'INSERT INTO uploads (member_id, s3_key, filename, mime_type, file_size) VALUES (?, ?, ?, ?, ?)',
'isssi',
(int) $_SESSION['member_id'], $s3_key, basename($filename), $mime, $size
);
echo json_encode(['file_id' => $file_id]);
How a Customer Uses It
From the customer’s perspective, nothing about this architecture is visible. They click an upload button, select a file, and see a progress indicator. The file uploads. They click a download link and a browser save dialog appears with the original filename. None of the S3 mechanics — bucket name, presigned form fields, AWS region, IAM user — appear in the page source, browser developer tools, or network traffic in any recognizable form.
What the network tab does show is a POST to /upload-url
(a small JSON object), a POST to an s3.amazonaws.com hostname (the
actual file going to S3), and a POST to /confirm-upload. For a
security-aware customer, this is reassuring: their file goes to cloud storage
directly, without routing through an opaque intermediary.
For downloads, they see a GET to /download-url followed by a redirect
to an S3 URL with a long query string. That URL is their file, available for
15 minutes. Sharing it with someone else works during that window; after expiry,
the link returns a 403.
Expiry Windows
Upload presigned POST:
'+20 minutes' ← enough for large files on slow connections
the upload must complete before the policy expires
reduce to 10 min if uploads are small and fast
Download presigned GET:
'+15 minutes' ← enough to open a tab and start the download stream
short enough that a leaked URL expires quickly
increase to 60 min for very large files on slow connections
Avoid:
'+7 days' ← a valid week-long download URL is a real exposure risk
'+1 year' ← permanently functional URL; treat it as public access
Security Hardening
- Validate MIME type server-side before signing. The client
reports its own MIME type, and the presigned POST policy pins
Content-Typeexactly to the validated value. A client cannot upload an executable disguised as a PDF without failing S3’s own signature check. - Never expose raw S3 keys to clients. Clients pass a
file_idinteger to download endpoints, not an S3 object key. The key-to-ID mapping lives in your database. A client cannot construct a valid presigned URL for any object they did not legitimately upload. - Validate the key prefix in confirm-upload. Before writing
any database row, the confirm endpoint checks that the reported key starts
with
uploads/{member_id}/. A client cannot claim an S3 key that belongs to another user. - Keep IAM permissions minimal. The credential used by your
server has
s3:PutObject,s3:GetObject,s3:ListBucket, ands3:DeleteObject. It does not haves3:DeleteBucket, ACL changes, or access to any other AWS service. Compromised credentials cannot destroy the bucket or affect other accounts. - Rate-limit URL generation endpoints. Presigned URL
generation is cheap, but endpoint flooding produces noise and can be used
to probe your MIME type allowlist. Apply the same file-based rate limiter
pattern used in
sign-in-process.php. - Enable server-side encryption on the bucket. In bucket Properties → Default encryption, enable at minimum SSE-S3. SSE-KMS adds key rotation control. Encryption is transparent to presigned URLs — signing behavior is unchanged.
- Enable S3 server access logging. Bucket → Properties → Server access logging. Captures every GetObject and PutObject with the presigned URL identifier, timestamp, and requester IP. Costs negligible storage and provides a forensic trail.
- Rotate IAM credentials on a schedule. IAM allows two
active keys per user. Create a new key, update
nonserve/.env, verify the app works, then delete the old key. Rotate at minimum annually or after any suspected exposure.
Checklist
AWS Console:
[ ] S3 bucket created in the region closest to your server
[ ] All four Block Public Access settings confirmed ON
[ ] IAM policy created with s3:PutObject, s3:GetObject, s3:ListBucket, s3:DeleteObject — no s3:DeleteBucket
[ ] Resource ARN in policy uses your actual bucket name
[ ] IAM user created with programmatic access only — no console login
[ ] Access key ID and secret copied and stored (secret shown only once)
[ ] CORS configured with your specific origin — not a wildcard *
[ ] localhost entry in CORS AllowedOrigins (remove before production deploy)
[ ] Default encryption enabled on the bucket (SSE-S3 minimum)
[ ] Server access logging enabled
Server configuration:
[ ] AWS_KEY, AWS_SECRET, AWS_REGION, S3_BUCKET added to nonserve/.env
[ ] .env.example updated with empty placeholder keys
[ ] AWS SDK installed: composer require aws/aws-sdk-php
[ ] uploads table created in the database
PHP endpoints:
[ ] upload-url.php validates session before any other logic
[ ] upload-url.php validates MIME type against a hardcoded allowlist
[ ] upload-url.php validates file size against a hardcoded max
[ ] upload-url.php key includes member_id prefix
[ ] confirm-upload.php validates key prefix matches session member_id
[ ] download-url.php looks up S3 key by file_id with member_id ownership check
[ ] download-url.php sets ResponseContentDisposition to force download dialog
Frontend:
[ ] FormData appends all policy fields before the file field
[ ] Upload code checks for HTTP 204 (not just response.ok)
[ ] confirm-upload call fires only after S3 returns 204
[ ] Download uses window.location.href — not a fetch or anchor click
[ ] Upload and download errors surfaced to the user — silent failure is confusing
Security:
[ ] No AWS credentials in any committed file, image, or environment variable baked into a build
[ ] Presigned POST expiry is 20 minutes or less
[ ] Presigned GET expiry is 15 minutes or less for sensitive files
[ ] Rate limiting applied to all three URL endpoints
[ ] IAM key rotation schedule established (annually minimum)