Fix AccessDenied Errors Uploading Django Files to S3
Fix AccessDenied from S3 when django-storages uploads files: IAM policy actions, bucket policies, Object Ownership and ACLs, KMS keys, and credentials.
Problem statement
Django uploads fail when using django-storages with Amazon S3, and the logs show:
botocore.exceptions.ClientError: An error occurred (AccessDenied) when calling the PutObject operation: Access Denied
Variants:
An error occurred (AccessControlListNotSupported) when calling the PutObject operation: The bucket does not allow ACLs
An error occurred (AccessDenied) when calling the HeadObject operation: Forbidden
An error occurred (KMS.AccessDeniedException) when calling the PutObject operation
S3 authorization is evaluated from several sources: the IAM identity's policies, the bucket policy, Block Public Access settings, Object Ownership (ACL) settings, KMS key policies, and organization-level policies. AccessDenied from any of them looks the same in Django. Django-storages also calls more operations than just PutObject: it may check existence with HeadObject before saving, which needs its own permission.
Quick answer
- Identify the operation that failed (
PutObject,HeadObject,GetObject,DeleteObject) from the error message. - Confirm which AWS identity Django uses:
aws sts get-caller-identitywith the same credentials or role. - Grant the identity
s3:PutObject,s3:GetObject,s3:DeleteObjectonarn:aws:s3:::bucket/*ands3:ListBucketonarn:aws:s3:::bucket. - If the error mentions ACLs, set
default_acltoNone(buckets with Object Ownership "Bucket owner enforced" reject ACLs). - If the bucket uses SSE-KMS, grant
kms:GenerateDataKeyandkms:Decrypton the key. - Check the bucket policy for explicit
Denystatements (for example requiring TLS, specific VPC endpoints, or encryption headers). - Test with the AWS CLI using the same identity, then verify an upload through Django.
Step-by-step solution
1. Find the failing operation
The operation name in the error tells you which permission is missing:
| Operation | Needed permission | When django-storages calls it |
|---|---|---|
PutObject | s3:PutObject | saving a file |
HeadObject | s3:GetObject (and s3:ListBucket for proper 404 responses) | exists(), size, name collision checks |
GetObject | s3:GetObject | reading or downloading files |
DeleteObject | s3:DeleteObject | deleting files |
ListObjectsV2 | s3:ListBucket | listdir(), some existence checks |
Without s3:ListBucket, S3 returns 403 instead of 404 for objects that do not exist. When django-storages checks whether a name is free, a 403 is raised as an error rather than treated as "not found", so uploads can fail on HeadObject even though PutObject would be allowed.
2. Confirm the identity
Credentials can come from settings, environment variables, a shared credentials file, or an instance, task, or pod role. Check which one boto3 actually uses in the app's environment:
python manage.py shell -c "
import boto3
print(boto3.client('sts').get_caller_identity()['Arn'])
"
Common surprises:
- an old
AWS_ACCESS_KEY_IDin the environment overrides the EC2 instance role you configured - the ECS task role is missing, so no credentials are found, or the execution role is confused with the task role
- the code runs as a different user whose
~/.aws/credentialscontains another account's keys
3. Grant a correct IAM policy
Attach a least-privilege policy to the role or user:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ObjectAccess",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::myapp-media/*"
},
{
"Sid": "BucketListing",
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": "arn:aws:s3:::myapp-media"
}
]
}
Note the two different resource ARNs: object actions apply to bucket/*, while ListBucket applies to the bucket ARN itself. Granting s3:ListBucket on bucket/* is a common mistake that silently grants nothing.
If uploads go under a prefix such as media/, you can scope further: arn:aws:s3:::myapp-media/media/* for objects, and a Condition with s3:prefix for listing.
4. ACL errors and Object Ownership
New S3 buckets default to Object Ownership: Bucket owner enforced, which disables ACLs. Requests that include an ACL header fail:
AccessControlListNotSupported: The bucket does not allow ACLs
Older django-storages configurations often set AWS_DEFAULT_ACL = "public-read". Remove ACLs:
STORAGES = {
"default": {
"BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": {
"bucket_name": "myapp-media",
"region_name": "eu-west-1",
"default_acl": None,
"file_overwrite": False,
"querystring_auth": True,
},
},
}
Control public access with a bucket policy (for truly public assets) or, better, with CloudFront and Origin Access Control, or signed URLs (querystring_auth=True) for private files.
If you set "public-read" and the bucket has Block Public Access enabled, S3 also rejects the request with AccessDenied, even when ACLs are allowed.
5. Bucket policy denies
An explicit Deny in the bucket policy overrides any Allow in IAM. Review the policy:
aws s3api get-bucket-policy --bucket myapp-media --query Policy --output text | python3 -m json.tool
Typical denies that affect Django:
- Require TLS:
"Condition": {"Bool": {"aws:SecureTransport": "false"}}. boto3 uses HTTPS by default, so this only fails ifuse_sslwas disabled or a custom endpoint useshttp://. - Require encryption headers: denies
PutObjectwithoutx-amz-server-side-encryption. Add encryption parameters in django-storages:
"OPTIONS": {
# ...
"object_parameters": {"ServerSideEncryption": "AES256"},
},
- Restrict to a VPC endpoint:
aws:SourceVpceconditions deny requests that do not come through the endpoint, for example from a developer laptop or a different subnet.
6. KMS-encrypted buckets
With default encryption using a customer managed KMS key (SSE-KMS), uploads also need permission to use the key:
{
"Effect": "Allow",
"Action": ["kms:GenerateDataKey", "kms:Decrypt"],
"Resource": "arn:aws:kms:eu-west-1:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab"
}
The key policy must also allow the account's IAM policies or the role directly. Errors mention KMS in the code or message.
7. Region and endpoint mismatches
A wrong region typically produces redirects or signature errors rather than AccessDenied, but some configurations surface it as 403. Set region_name explicitly to the bucket's region:
aws s3api get-bucket-location --bucket myapp-media
A LocationConstraint of null means us-east-1.
8. Reproduce with the AWS CLI
Using the same identity (on the server, or with the same role), try each operation:
echo "test" > /tmp/s3-test.txt
aws s3api put-object --bucket myapp-media --key healthchecks/s3-test.txt --body /tmp/s3-test.txt
aws s3api head-object --bucket myapp-media --key healthchecks/s3-test.txt
aws s3api head-object --bucket myapp-media --key healthchecks/does-not-exist.txt
aws s3api delete-object --bucket myapp-media --key healthchecks/s3-test.txt
The head-object on a missing key should return 404 Not Found. A 403 Forbidden there means s3:ListBucket is missing.
For deeper analysis, the IAM Policy Simulator and CloudTrail events for S3 data operations (if enabled) show which policy denied the request.
9. Verify through Django
python manage.py shell -c "
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
name = default_storage.save('healthchecks/django-s3-test.txt', ContentFile(b'ok'))
print('saved', name, 'exists', default_storage.exists(name))
print('url', default_storage.url(name))
default_storage.delete(name)
print('deleted', not default_storage.exists(name))
"
Verification check:
- save, exists, and delete all succeed
- the generated URL is accessible as intended (signed URL works, or public URL through CloudFront)
- a real upload through the application's form succeeds
- the identity has no broader permissions than the actions listed above
Explanation
S3 decides access by combining identity policies, resource policies, ownership settings, encryption key policies, and account guardrails, and a single explicit deny anywhere wins. django-storages exercises several S3 API operations for what looks like one "upload", so a policy that allows PutObject alone is not enough. Mapping the failing operation to its permission, confirming the identity, and checking the deny sources in order resolves nearly every case.
Edge cases / notes
Cross-account buckets
When the bucket belongs to another AWS account, both the bucket policy (granting the role) and the role's IAM policy must allow the actions.
Presigned uploads from browsers
Presigned URLs carry the permissions of the identity that signed them. If a browser upload fails with AccessDenied, check the signing identity's permissions, the URL expiry, and that the request headers (such as Content-Type) match what was signed.
S3-compatible providers
Cloudflare R2, MinIO, and others implement subsets of S3 features. ACLs and some headers may be unsupported regardless of permissions.
Managing the policy as code
Hand-edited IAM policies drift. Keep the storage permissions next to the application in infrastructure code so reviews catch mistakes such as ListBucket on the wrong resource. A Terraform example:
data "aws_iam_policy_document" "django_media" {
statement {
sid = "ObjectAccess"
actions = ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"]
resources = ["${aws_s3_bucket.media.arn}/*"]
}
statement {
sid = "BucketListing"
actions = ["s3:ListBucket"]
resources = [aws_s3_bucket.media.arn]
}
}
resource "aws_iam_role_policy" "django_media" {
name = "django-media-access"
role = aws_iam_role.django_app.id
policy = data.aws_iam_policy_document.django_media.json
}
resource "aws_s3_bucket_ownership_controls" "media" {
bucket = aws_s3_bucket.media.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
resource "aws_s3_bucket_public_access_block" "media" {
bucket = aws_s3_bucket.media.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
The ownership and public access settings make the ACL decision explicit, so application settings such as default_acl=None match the bucket by design.
Separate identities per environment
Give staging and production different roles and buckets. A staging credential that can write to the production bucket turns a staging bug into a production data problem. Bucket names in settings should come from environment variables, and each environment's role should only have access to its own bucket.
Checking permissions in a deploy step
A management command that writes, checks, and deletes a small object (as in step 9) run during deploys catches permission regressions before users upload files. Run it with the production identity, for example as a one-off task using the same task role on ECS.
Common mistakes
- Granting
s3:ListBucketonbucket/*instead of the bucket ARN. - Keeping
AWS_DEFAULT_ACL = "public-read"on a bucket that disallows ACLs. - Leaving old access keys in the environment that override the instance role.
- Forgetting KMS permissions for encrypted buckets.
Next steps
- Review the full setup in How to Store Django Media Files on Amazon S3.
- For R2-specific differences, see Store Django Media Files on Cloudflare R2.
- Keep credentials out of code with Manage Django Secrets in Production.
- For local disk uploads, see Fix Permission Denied on Django Media Uploads.
FAQ
What IAM permissions does django-storages need for S3?
Typically s3:PutObject, s3:GetObject, and s3:DeleteObject on arn:aws:s3:::bucket/*, plus s3:ListBucket on arn:aws:s3:::bucket so existence checks return 404 instead of 403.
Why does S3 say the bucket does not allow ACLs?
The bucket's Object Ownership is set to bucket owner enforced, which disables ACLs. Set default_acl to None in django-storages and control access with policies or signed URLs.
Why does HeadObject fail with 403 Forbidden?
Without s3:ListBucket, S3 returns 403 for keys that do not exist. django-storages checks existence before saving, so grant ListBucket on the bucket ARN.
How do I check which AWS identity Django uses?
Run boto3.client("sts").get_caller_identity() in a Django shell with the production environment. It shows the ARN of the credentials boto3 resolved.