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.

S3 authorization layersDjangoboto3 credentialsIAM policyobject and bucketBucket policyexplicit deniesObjectstored or refusedAny explicit deny wins, and django-storages calls more operations than PutObject.

Quick answer

  1. Identify the operation that failed (PutObject, HeadObject, GetObject, DeleteObject) from the error message.
  2. Confirm which AWS identity Django uses: aws sts get-caller-identity with the same credentials or role.
  3. Grant the identity s3:PutObject, s3:GetObject, s3:DeleteObject on arn:aws:s3:::bucket/* and s3:ListBucket on arn:aws:s3:::bucket.
  4. If the error mentions ACLs, set default_acl to None (buckets with Object Ownership "Bucket owner enforced" reject ACLs).
  5. If the bucket uses SSE-KMS, grant kms:GenerateDataKey and kms:Decrypt on the key.
  6. Check the bucket policy for explicit Deny statements (for example requiring TLS, specific VPC endpoints, or encryption headers).
  7. 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:

OperationNeeded permissionWhen django-storages calls it
PutObjects3:PutObjectsaving a file
HeadObjects3:GetObject (and s3:ListBucket for proper 404 responses)exists(), size, name collision checks
GetObjects3:GetObjectreading or downloading files
DeleteObjects3:DeleteObjectdeleting files
ListObjectsV2s3:ListBucketlistdir(), some existence checks
which permission does the call need?IAM actionsave()s3:PutObjectopen() / url() signeds3:GetObjectdelete()s3:DeleteObjectexists(), listdir()!s3:ListBucket (on the bucket ARN)default_acl sets3:PutObjectAclListBucket applies to the bucket; object actions to bucket/*django-deployment.com
Without ListBucket, a missing object returns 403 instead of 404.

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_ID in 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/credentials contains 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"
    }
  ]
}
two statements, two resource forms"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],"Resource": "arn:aws:s3:::myapp-media/*""Action": ["s3:ListBucket"],"Resource": "arn:aws:s3:::myapp-media"1231Only if the app deletes files.2Object actions need the /* form.3ListBucket needs the bucket itself, no /*.Mixing up the two resource forms is the classic AccessDenieddjango-deployment.com
Scope to a prefix like myapp-media/uploads/* when the bucket is shared.

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
ACLs are disabled on new bucketsAWS_DEFAULT_ACL = "public-read"bucket has BucketOwnerEnforced✕AccessControlListNotSupported✕or AccessDenied without PutObjectAclAWS_DEFAULT_ACL = None✓no ACL sent with the upload✓access via bucket policy or CloudFront✓private by defaultPublic access belongs in a bucket or CDN policy, not per-object ACLsdjango-deployment.com
querystring_auth then decides whether URLs are signed.

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
any explicit deny winsExplicit Deny anywhereSCP, bucket policy, IAM: requestfailsBucket policy conditionsVPC endpoint, TLS, encryptionheadersIAM policy of the identitymust Allow the actionKMS key policyif the bucket uses SSE-KMSAn Allow in IAM cannot override a Deny in the bucket policydjango-deployment.com
CloudTrail shows which policy produced the denial.

Typical denies that affect Django:

  • Require TLS: "Condition": {"Bool": {"aws:SecureTransport": "false"}}. boto3 uses HTTPS by default, so this only fails if use_ssl was disabled or a custom endpoint uses http://.
  • Require encryption headers: denies PutObject without x-amz-server-side-encryption. Add encryption parameters in django-storages:
"OPTIONS": {
    # ...
    "object_parameters": {"ServerSideEncryption": "AES256"},
},
  • Restrict to a VPC endpoint: aws:SourceVpce conditions 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:ListBucket on bucket/* 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

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.

Keep going