Yes. Based on your constraints, I would simplify the implementation considerably.
Final scope
Private S3 bucket
↑ ↓
Direct browser upload/download using presigned URLs
↑
Application on EC2
↑
EC2 IAM Role
NO AWS credentials for developers
NO AWS credentials for application users
NO video stored on EC2
NO CloudFront
NO VPC Endpoint
NO S3 Versioning
NO public bucket
Presigned URLs are designed exactly for this: the application can grant temporary upload/download access without giving the user AWS credentials. The URL can only perform actions permitted to the IAM principal that generated it. AWS Documentation
There is one thing I would fix before implementation, though.
0. Important: your bucket name videos.wizbrand.com
AWS currently documents a TLS limitation for S3 virtual-hosted URLs: S3’s wildcard SSL certificate matches bucket names without dots, and AWS says bucket names containing periods are valid but not recommended for use cases other than static website hosting. AWS Documentation
Your bucket:
videos.wizbrand.com
would normally produce a URL resembling:
https://videos.wizbrand.com.s3.ap-south-1.amazonaws.com/...
Therefore, if this bucket is still empty, I recommend changing it now to something like:
wizbrand-videos-prod
or:
videos-wizbrand-com
The bucket name does not need to be videos.wizbrand.com. Since you aren’t using CloudFront, end users won’t see a pretty custom hostname anyway.
If you absolutely want to keep videos.wizbrand.com, JavaScript AWS SDK v3 supports:
const s3 = new S3Client({
region: process.env.AWS_REGION,
forcePathStyle: true,
});
AWS documents forcePathStyle for SDK v3. AWS Documentation
For the rest below, I’ll keep your current name:
videos.wizbrand.com
PART 1 — DevOps implementation
I’d implement the DevOps side in this order.
Step 1 — Verify existing bucket
First find its region:
aws s3api get-bucket-location \
--bucket videos.wizbrand.com
Check Block Public Access:
aws s3api get-public-access-block \
--bucket videos.wizbrand.com
Check versioning:
aws s3api get-bucket-versioning \
--bucket videos.wizbrand.com
You want versioning to return either nothing or not be Enabled.
If versioning was never enabled, leave it alone.
You specifically don’t want versioning, so don’t create a Terraform aws_s3_bucket_versioning resource.
Step 2 — Enable complete Block Public Access
The bucket must remain private.
AWS recommends enabling all four S3 Block Public Access controls. AWS Documentation
CLI:
aws s3api put-public-access-block \
--bucket videos.wizbrand.com \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
Verify:
aws s3api get-public-access-block \
--bucket videos.wizbrand.com
Expected:
{
"PublicAccessBlockConfiguration": {
"BlockPublicAcls": true,
"IgnorePublicAcls": true,
"BlockPublicPolicy": true,
"RestrictPublicBuckets": true
}
}
The presigned URLs will still work. You do not need to make the bucket public for presigned access.
Step 3 — Configure Object Ownership
Use:
BucketOwnerEnforced
This disables ACLs entirely and makes permissions policy-based. AWS recommends this model for modern S3 usage. AWS Documentation
CLI:
aws s3api put-bucket-ownership-controls \
--bucket videos.wizbrand.com \
--ownership-controls '{
"Rules": [
{
"ObjectOwnership": "BucketOwnerEnforced"
}
]
}'
Verify:
aws s3api get-bucket-ownership-controls \
--bucket videos.wizbrand.com
Your developers should therefore never send:
ACL: public-read
ACL: private
ACL: bucket-owner-full-control
No ACL is required.
Step 4 — Explicitly configure S3 encryption
S3 already encrypts new objects by default, but I would explicitly manage the setting.
For now:
SSE-S3
AES256
is sufficient.
CLI:
aws s3api put-bucket-encryption \
--bucket videos.wizbrand.com \
--server-side-encryption-configuration '{
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "AES256"
},
"BucketKeyEnabled": false
}
]
}'
No KMS permissions are then required.
Step 5 — Establish the S3 object structure
DevOps and developers should agree on one structure.
I recommend:
tenants/
{tenant_id}/
users/
{user_id}/
videos/
{video_id}/
original.mp4
Example:
tenants/tenant-a7d91/
users/user-a810/
videos/video-928ab/
original.mp4
Actual full key:
tenants/tenant-a7d91/users/user-a810/videos/video-928ab/original.mp4
The backend generates this.
Never allow the browser to say:
s3_key = "whatever/user/sends.mp4"
Step 6 — Create EC2 application IAM role
Create:
wizbrand-video-app-role
This role is for the application, not developers.
Trust relationship:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
CLI:
aws iam create-role \
--role-name wizbrand-video-app-role \
--assume-role-policy-document file://ec2-trust-policy.json
Step 7 — Create the S3 application IAM policy
Create:
wizbrand-video-s3-policy
I would start with this:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VideoObjectAccess",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:GetObjectAttributes",
"s3:PutObject",
"s3:DeleteObject",
"s3:AbortMultipartUpload",
"s3:ListMultipartUploadParts"
],
"Resource": "arn:aws:s3:::videos.wizbrand.com/tenants/*"
},
{
"Sid": "BucketMetadata",
"Effect": "Allow",
"Action": [
"s3:GetBucketLocation"
],
"Resource": "arn:aws:s3:::videos.wizbrand.com"
}
]
}
Notice something deliberately missing:
s3:ListBucket
I would not give it initially.
Your application’s database should determine:
Which videos does this user have?
rather than:
List everything under this S3 prefix.
That reduces S3 permissions.
Create policy:
aws iam create-policy \
--policy-name wizbrand-video-s3-policy \
--policy-document file://wizbrand-video-s3-policy.json
Attach:
aws iam attach-role-policy \
--role-name wizbrand-video-app-role \
--policy-arn arn:aws:iam::<AWS_ACCOUNT_ID>:policy/wizbrand-video-s3-policy
Step 8 — Create EC2 Instance Profile
Create:
aws iam create-instance-profile \
--instance-profile-name wizbrand-video-app-instance-profile
Add role:
aws iam add-role-to-instance-profile \
--instance-profile-name wizbrand-video-app-instance-profile \
--role-name wizbrand-video-app-role
Then attach that instance profile to your EC2 instance.
Important: an EC2 instance normally has one IAM instance profile.
If your application EC2 already has an IAM role, don’t replace it blindly. Instead attach:
wizbrand-video-s3-policy
to the existing EC2 role.
That is usually cleaner.
Step 9 — Bucket policy: enforce HTTPS
Create:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyInsecureTransport",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::videos.wizbrand.com",
"arn:aws:s3:::videos.wizbrand.com/*"
],
"Condition": {
"Bool": {
"aws:SecureTransport": "false"
}
}
}
]
}
Save as:
bucket-policy.json
Apply:
aws s3api put-bucket-policy \
--bucket videos.wizbrand.com \
--policy file://bucket-policy.json
Notice that there is no:
"Allow": {
"Principal": "*"
}
The bucket remains private.
Step 10 — Configure CORS
This is required because the user’s browser will upload directly to S3.
Suppose your application frontend is:
https://app.wizbrand.com
Use:
[
{
"AllowedHeaders": [
"*"
],
"AllowedMethods": [
"GET",
"PUT",
"HEAD"
],
"AllowedOrigins": [
"https://app.wizbrand.com"
],
"ExposeHeaders": [
"ETag"
],
"MaxAgeSeconds": 3600
}
]
Apply:
aws s3api put-bucket-cors \
--bucket videos.wizbrand.com \
--cors-configuration file://cors.json
If your real application is:
https://wizbrand.com
use that instead.
For multiple environments:
"AllowedOrigins": [
"https://wizbrand.com",
"https://staging.wizbrand.com"
]
Do not use:
"AllowedOrigins": ["*"]
unless there is genuinely no alternative.
ETag is exposed because it becomes useful for multipart video uploads.
Step 11 — Multipart upload cleanup
For video applications, multipart upload is worth supporting even if you don’t need it on day one.
If a user starts uploading 3 GB and abandons the browser after uploading 1 GB, the incomplete parts cost storage until cleaned up.
Create:
{
"Rules": [
{
"ID": "AbortIncompleteVideoUploads",
"Status": "Enabled",
"Filter": {
"Prefix": "tenants/"
},
"AbortIncompleteMultipartUpload": {
"DaysAfterInitiation": 1
}
}
]
}
Apply:
aws s3api put-bucket-lifecycle-configuration \
--bucket videos.wizbrand.com \
--lifecycle-configuration file://lifecycle.json
This doesn’t delete completed videos.
It only cleans abandoned multipart uploads.
Step 12 — Test IAM role from EC2
SSH/SSM to the application EC2.
Do NOT configure:
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
Run:
aws sts get-caller-identity
It should show an assumed role similar to:
wizbrand-video-app-role
Test write without creating a local file:
echo "wizbrand-test" | \
aws s3 cp - \
s3://videos.wizbrand.com/tenants/test/users/test/videos/test/test.txt
Read:
aws s3 cp \
s3://videos.wizbrand.com/tenants/test/users/test/videos/test/test.txt -
Delete:
aws s3 rm \
s3://videos.wizbrand.com/tenants/test/users/test/videos/test/test.txt
Then verify something outside the approved prefix fails:
echo "test" | \
aws s3 cp - \
s3://videos.wizbrand.com/test.txt
Expected:
AccessDenied
Excellent. That proves the role is scoped correctly.
Step 13 — Terraform version
Since the bucket already exists, I wouldn’t attempt to recreate it.
You can manage its configuration independently:
variable "bucket_name" {
type = string
default = "videos.wizbrand.com"
}
variable "allowed_origins" {
type = list(string)
default = [
"https://app.wizbrand.com"
]
}
Public access
resource "aws_s3_bucket_public_access_block" "videos" {
bucket = var.bucket_name
block_public_acls = true
ignore_public_acls = true
block_public_policy = true
restrict_public_buckets = true
}
Ownership
resource "aws_s3_bucket_ownership_controls" "videos" {
bucket = var.bucket_name
rule {
object_ownership = "BucketOwnerEnforced"
}
}
Encryption
resource "aws_s3_bucket_server_side_encryption_configuration" "videos" {
bucket = var.bucket_name
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
CORS
resource "aws_s3_bucket_cors_configuration" "videos" {
bucket = var.bucket_name
cors_rule {
allowed_headers = ["*"]
allowed_methods = [
"GET",
"HEAD",
"PUT"
]
allowed_origins = var.allowed_origins
expose_headers = [
"ETag"
]
max_age_seconds = 3600
}
}
Multipart cleanup
resource "aws_s3_bucket_lifecycle_configuration" "videos" {
bucket = var.bucket_name
rule {
id = "abort-incomplete-video-uploads"
status = "Enabled"
filter {
prefix = "tenants/"
}
abort_incomplete_multipart_upload {
days_after_initiation = 1
}
}
}
Step 14 — Terraform IAM policy
data "aws_iam_policy_document" "video_s3_access" {
statement {
sid = "VideoObjectAccess"
actions = [
"s3:GetObject",
"s3:GetObjectAttributes",
"s3:PutObject",
"s3:DeleteObject",
"s3:AbortMultipartUpload",
"s3:ListMultipartUploadParts"
]
resources = [
"arn:aws:s3:::${var.bucket_name}/tenants/*"
]
}
statement {
sid = "BucketMetadata"
actions = [
"s3:GetBucketLocation"
]
resources = [
"arn:aws:s3:::${var.bucket_name}"
]
}
}
resource "aws_iam_policy" "video_s3_access" {
name = "wizbrand-video-s3-policy"
policy = data.aws_iam_policy_document.video_s3_access.json
}
If your EC2 already has a role:
variable "application_ec2_role_name" {
type = string
}
resource "aws_iam_role_policy_attachment" "video_s3" {
role = var.application_ec2_role_name
policy_arn = aws_iam_policy.video_s3_access.arn
}
This is probably what I would use in your environment.
Step 15 — Terraform HTTPS bucket policy
data "aws_iam_policy_document" "videos_bucket_policy" {
statement {
sid = "DenyInsecureTransport"
effect = "Deny"
principals {
type = "*"
identifiers = ["*"]
}
actions = [
"s3:*"
]
resources = [
"arn:aws:s3:::${var.bucket_name}",
"arn:aws:s3:::${var.bucket_name}/*"
]
condition {
test = "Bool"
variable = "aws:SecureTransport"
values = ["false"]
}
}
}
resource "aws_s3_bucket_policy" "videos" {
bucket = var.bucket_name
policy = data.aws_iam_policy_document.videos_bucket_policy.json
}
That’s basically your entire AWS infrastructure requirement.
PART 2 — What DevOps gives the developers
Developers do not receive:
AWS console account
IAM User
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
S3 bucket credentials
You give them only application configuration:
AWS_REGION=ap-south-1
VIDEO_S3_BUCKET=videos.wizbrand.com
VIDEO_UPLOAD_URL_EXPIRY_SECONDS=900
VIDEO_DOWNLOAD_URL_EXPIRY_SECONDS=300
VIDEO_MAX_SIZE_BYTES=5368709120
Change the region to your actual bucket region.
For the dotted bucket workaround, if applicable:
S3_FORCE_PATH_STYLE=true
That’s configuration, not a secret.
PART 3 — Developer implementation
Developer architecture becomes extremely simple:
┌─────────────────┐
│ User │
└───────┬─────────┘
│
Authenticate
│
▼
┌────────────────────┐
│ Application / EC2 │
│ │
│ Check tenant │
│ Check user │
│ Generate S3 key │
│ Generate URL │
└───────┬────────────┘
│
IAM Role
│
▼
UPLOAD:
User ───── presigned PUT ─────────────► S3
DOWNLOAD:
User ◄──── presigned GET ────────────── S3
DELETE:
User ──► Application ── DeleteObject ─► S3
Nothing passes through EC2 except API messages.
PART 4 — Developer AWS SDK initialization
For Node.js/TypeScript, for example:
import { S3Client } from "@aws-sdk/client-s3";
export const s3 = new S3Client({
region: process.env.AWS_REGION,
// Recommended only because your existing
// bucket name contains dots.
forcePathStyle: true,
});
Notice:
credentials: {
accessKeyId: "...",
secretAccessKey: "..."
}
is completely absent.
AWS SDK running on EC2 automatically obtains temporary credentials from the EC2 IAM role.
PART 5 — Generate upload URL
Backend:
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const key =
`tenants/${tenantId}/users/${userId}/videos/${videoId}/original.mp4`;
const command = new PutObjectCommand({
Bucket: process.env.VIDEO_S3_BUCKET,
Key: key,
ContentType: "video/mp4"
});
const uploadUrl = await getSignedUrl(
s3,
command,
{
expiresIn: 900
}
);
Then backend returns:
{
"videoId": "87f37...",
"uploadUrl": "https://..."
}
PART 6 — Browser uploads directly to S3
Frontend:
await fetch(uploadUrl, {
method: "PUT",
headers: {
"Content-Type": file.type
},
body: file
});
Flow:
Laptop/browser
│
│ video bytes
▼
S3
NOT:
Laptop
↓ video
EC2
↓ video
S3
That’s exactly what you wanted.
PART 7 — Generate download URL
Backend:
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const command = new GetObjectCommand({
Bucket: process.env.VIDEO_S3_BUCKET,
Key: video.s3Key
});
const downloadUrl = await getSignedUrl(
s3,
command,
{
expiresIn: 300
}
);
Return the URL.
The user then downloads directly:
Browser ◄──────── S3
AWS provides this exact presigned-download pattern in its current JavaScript SDK documentation. AWS Documentation
PART 8 — Delete
I recommend delete continue through EC2:
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
await s3.send(
new DeleteObjectCommand({
Bucket: process.env.VIDEO_S3_BUCKET,
Key: video.s3Key
})
);
The backend should first execute something conceptually like:
SELECT *
FROM videos
WHERE id = :video_id
AND tenant_id = :authenticated_tenant_id
AND user_id = :authenticated_user_id;
Only if that succeeds should it call S3.
Do not give users presigned DELETE URLs.
There is no performance advantage worth the extra risk.
PART 9 — Database responsibility
S3 should not be your database.
Keep something like:
videos
id
tenant_id
user_id
s3_key
original_filename
content_type
size_bytes
status
created_at
updated_at
Example:
id = video-81273
tenant_id = tenant-123
user_id = user-456
s3_key =
tenants/tenant-123/users/user-456/videos/video-81273/original.mp4
This is extremely important for multi-tenancy.
PART 10 — Developer security rule
The browser must never determine:
tenant_id
S3 key
S3 path
Authoritative tenant information must come from:
authenticated session / JWT
Bad:
const tenantId = req.body.tenantId;
Good:
const tenantId = req.auth.tenantId;
const userId = req.auth.userId;
Then:
const key =
`tenants/${tenantId}/users/${userId}/videos/${videoId}/original.mp4`;
This prevents:
Tenant A
↓
request Tenant B path
↓
download/delete Tenant B video
PART 11 — Revised responsibility split
| Requirement | DevOps | Developer |
|---|---|---|
| S3 bucket | ✅ | |
| Public access disabled | ✅ | |
| Object ownership | ✅ | |
| Encryption | ✅ | |
| Bucket policy | ✅ | |
| IAM EC2 role | ✅ | |
| IAM S3 policy | ✅ | |
| Attach role to EC2 | ✅ | |
| S3 CORS | ✅ | Input |
| Multipart cleanup | ✅ | |
| Terraform | ✅ | |
| AWS credentials | None given | None |
| S3 key format | Agree | ✅ implement |
| Tenant authorization | ✅ | |
| User authorization | ✅ | |
| Video database table | ✅ | |
| Generate presigned upload URL | ✅ | |
| Direct browser → S3 upload | ✅ | |
| Generate presigned download URL | ✅ | |
| Direct S3 → browser download | ✅ | |
| DeleteObject | IAM permission | ✅ |
| Multipart video upload | IAM permission | ✅ |
| File-size/type validation | ✅ | |
| Cross-tenant security tests | ✅ |
Final implementation sequence
I would execute the project in this order:
DEVOPS
│
├── 1. Decide dotted bucket name issue
├── 2. Verify bucket/versioning
├── 3. Block Public Access
├── 4. BucketOwnerEnforced
├── 5. SSE-S3 encryption
├── 6. CORS
├── 7. Multipart cleanup
├── 8. HTTPS bucket policy
├── 9. IAM application policy
├─ 10. Attach policy to EC2 IAM role
└─ 11. Verify role using AWS CLI
│
▼
HANDOFF
│
├── bucket name
├── region
├── key convention
├── URL expiry
└── NO AWS credentials
│
▼
DEVELOPER
│
├── POST /videos/upload
│ ↓
│ generate presigned PUT
│
├── browser → S3
│
├── GET /videos/{id}/download
│ ↓
│ presigned GET
│
├── DELETE /videos/{id}
│ ↓
│ backend → S3 DeleteObject
│
└── Tenant security tests
This is the architecture I’d use for Wizbrand V1. It’s small, secure, inexpensive, and doesn’t prematurely add CloudFront, KMS, VPC endpoints, STS tenant roles, or other infrastructure you don’t currently need.