February 20, 2026

Don’t expose yourself in public — let AWS error messages do it for you

Written by

Daniel Grzelak

Discover any publicly exposed AWS resource

In my last post, I walked through techniques for safely testing AWS resource access: malformed request probes, the 3-topic validation method, mutation discovery. It worked, mostly. It was also complicated. Each API action needed its own mutation. Each mutation needed validation against three test targets. Maintenance was a thing. And if you’re going to use it in production, you’re always at risk of AWS silently changing the API behavior.

Then Sam Cox showed the way. Thank you Sam.

Turns out AWS will just tell you! You don’t need to reverse-engineer validation order or craft malformed requests. About a month ago, AWS rolled out friendly IAM error messages to help users debug policies. A noble cause, but one that deprecated years of reconnaissance research and simplified everything. Awseye needs a big update.

TL;DR: Apply a deny-all session policy, make a request, and read the error message. AWS tells you exactly which policy layer blocked you. If your session policy was the blocker, the resource policy would have allowed it. That’s public exposure, confirmed by AWS itself.

The error message changes

For years, AWS access denied errors were maddeningly vague. You’d get something like:

User: arn:aws:iam::123456789012:user/John is not authorized to perform: s3:GetObject

Cool. But why? Was it the bucket policy? The IAM policy? An SCP? A permissions boundary? Good luck figuring that out. You’d end up in the IAM policy simulator, or staring at CloudTrail logs, or just guessing and iterating.

AWS changed this in late January 2026. The new format:

User: <ARN> is not authorized to perform: <action> on resource: <resource> because <reason>

That “because” clause is everything. It now reveals:

  • Which policy type blocked the request
  • Explicit vs implicit denial: did a Deny statement block you, or was there just no Allow?
  • The policy ARN itself (sometimes): for identity-based policies, SCPs, and RCPs, you often get the exact policy that caused the denial

There are 8 policy types, each with distinct error signatures:

Policy Type Explicit Deny Implicit Deny
SCP explicit deny in a service control policy no service control policy allows
RCP explicit deny in a resource control policy N/A (RCPs are deny-only)
VPC Endpoint explicit deny in a VPC endpoint policy no VPC endpoint policy allows
Permissions Boundary explicit deny in a permissions boundary no permissions boundary allows
Session Policy explicit deny in a session policy no session policy allows
Resource-Based explicit deny in a resource-based policy no resource-based policy allows
Trust Policy explicit deny in the role trust policy no role trust policy allows
Identity-Based explicit deny in an identity-based policy no identity-based policy allows

A few caveats from the AWS docs: if multiple policies of the same type deny a request, the error won’t say how many. If multiple policy types deny, only one is mentioned. And some services don’t support this format yet. But for most services, AWS is now telling you exactly what happened. You just have to listen.

The session policy trick

Here’s the insight Sam Cox pointed me to that makes this useful for exposure detection.

Session policies are evaluated after resource-based policies. If you assume a role with a deny-all session policy and make a request, AWS evaluates the resource policy first. Then your session policy denies. The error message tells you which one blocked.

If your session policy was the blocker, the resource policy passed. That’s public exposure.

Setup

1. Create a role you can assume:

aws iam create-role --role-name exposure-test-role \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": {"AWS": "arn:aws:iam::ACCOUNT_ID:root"},
      "Action": "sts:AssumeRole"
    }]
  }'

2. Assume it with a deny-all session policy:

aws sts assume-role --role-arn arn:aws:iam::$ACCOUNT_ID:role/exposure-test-role \
  --role-session-name test \
  --policy '{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"*","Resource":"*"}]}'

3. Make requests and classify the errors:

"deny in a session policy" → PUBLIC (resource policy would have allowed)
"deny in a resource-based policy" → PRIVATE (resource explicitly denied)
"no identity-based policy allows" → NO_POLICY (no resource policy exists)

De-identified real example

Public queue:

User: arn:aws:sts::111111111111:assumed-role/exposure-test-role/test is not authorized
to perform: sqs:getqueueattributes on resource: arn:aws:sqs:us-east-1:222222222222:public-queue
with an explicit deny in a session policy

Classification: PUBLIC (session policy blocked it, resource policy would have allowed)

Private queue:

User: arn:aws:sts::111111111111:assumed-role/exposure-test-role/test is not authorized
to perform: sqs:getqueueattributes on resource: arn:aws:sqs:us-east-1:222222222222:private-queue
because no identity-based policy allows the sqs:getqueueattributes action

Classification: NO_POLICY (no resource policy allows access)

An update to sns-buster

sns-buster is an open-source tool for testing SNS topic exposure across all 14 SNS actions. It previously used complex techniques to test for public exposure. I added this technique so you can skip all the fancy error-prone mutations:

sns-buster --session-errors arn:aws:iam::111111111111:role/test-role \
  arn:aws:sns:us-east-1:222222222222:target-topic

Assuming role with deny-all session policy...
Session: sns-buster-safe-1771312885389
Policy: Deny:*:*

Testing: arn:aws:sns:us-east-1:222222222222:target-topic

Action                      Status    Classification
----------------------------------------------------
GetTopicAttributes          403       PUBLIC
Publish                     403       PUBLIC
Subscribe                   403       PUBLIC
DeleteTopic                 403       PUBLIC
...

All 14 SNS actions classified in seconds. The error messages do the work.

Why this beats malformed probes

The malformed probe technique from my last post required discovering validation mutations for each API action, then testing against three reference targets to confirm the behavior. It worked, but it was fragile. AWS could change validation order at any time, and you’d need to maintain mutations for every action you cared about.

This approach is simpler. One deny-all policy works for every action on every service. The error messages are part of AWS’s documented troubleshooting interface, so they’re unlikely to disappear. And instead of inferring exposure from the difference between 400 and 403, AWS explicitly names the policy type that blocked you.

Because your session policy always denies, you can safely test destructive actions like DeleteBucket or DeleteTopic, and sensitive reads like GetSecretValue. The request never succeeds. You get the error message revealing exposure, but the action never executes.

Edge cases and things to know

A few things to watch out for…

CloudTrail stealth: Since your session policy denies the request before it reaches the target resource, CloudTrail logs only appear in your account, not the target’s. You can probe without leaving traces in their logs.

Cross-account S3: S3 doesn’t return verbose error messages for cross-account requests. You’ll get a generic “Access Denied” instead of the policy type breakdown. Other services like SQS, Lambda, and Secrets Manager work fine cross-account.

SCPs and RCPs: Organization policies evaluate first. If an SCP blocks you, you’ll see that error instead of resource policy errors. This can mask whether the resource policy would have allowed.

Non-existent vs no-policy: “no identity-based policy allows” is ambiguous. Could be a resource with no public policy, or a resource that doesn’t exist. Additional enumeration techniques can distinguish these if needed.

What a “public” policy actually looks like

To test exposure detection, you need to understand what makes a resource public in the first place. Setting Principal: "*" isn’t always enough. Different services have different quirks.

SNS rejects action wildcards. Try "Action": "SNS:*" and AWS won’t save the policy. You must enumerate specific actions:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": "*",
    "Action": [
      "sns:Publish",
      "sns:Subscribe",
      "sns:GetTopicAttributes",
      "sns:ListSubscriptionsByTopic"
    ],
    "Resource": "arn:aws:sns:us-east-1:123456789012:my-topic"
  }]
}

SQS and Lambda accept wildcards. These services are more permissive:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": "*",
    "Action": "sqs:*",
    "Resource": "arn:aws:sqs:us-east-1:123456789012:my-queue"
  }]
}

EventBridge needs multiple resource patterns. The event bus itself and the rules on it use different ARN formats:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": "*",
      "Action": "events:PutEvents",
      "Resource": "arn:aws:events:us-east-1:123456789012:event-bus/my-bus"
    },
    {
      "Effect": "Allow",
      "Principal": "*",
      "Action": "events:*",
      "Resource": "arn:aws:events:us-east-1:123456789012:rule/my-bus/*"
    }
  ]
}

Serverless Application Repository is its own thing. Forget everything you know about IAM policies. SAR uses a completely custom format with no Resource field, no Effect field, and unprefixed action names:

{
  "Statements": [{
    "Actions": ["GetApplication", "Deploy"],
    "Principals": ["*"],
    "StatementId": "public"
  }]
}

Principal formats matter inconsistently. Some services treat "*" and "AWS": "*" identically. Others don’t. The only way to know is to test.

A generic exposure testing script

Below is a minimal bash script that implements the session policy technique. I’m using SQS here to show that this works beyond just SNS. Any service with resource policies will do. If you want to try some random queues, maybe you’ll find an open one on Awseye.

#!/bin/bash
ROLE_ARN="$1"; shift; QUEUES=("$@")

# Assume role with deny-all session policy
CREDS=$(aws sts assume-role --role-arn "$ROLE_ARN" --role-session-name "test" \
  --policy '{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"*","Resource":"*"}]}' --output json)
export AWS_ACCESS_KEY_ID=$(echo "$CREDS" | jq -r '.Credentials.AccessKeyId')
export AWS_SECRET_ACCESS_KEY=$(echo "$CREDS" | jq -r '.Credentials.SecretAccessKey')
export AWS_SESSION_TOKEN=$(echo "$CREDS" | jq -r '.Credentials.SessionToken')

for arn in "${QUEUES[@]}"; do
  region=$(echo "$arn" | cut -d: -f4)
  account=$(echo "$arn" | cut -d: -f5)
  name=$(echo "$arn" | cut -d: -f6)
  error=$(aws sqs get-queue-attributes \
    --queue-url "https://sqs.$region.amazonaws.com/$account/$name" \
    --attribute-names All --region "$region" 2>&1)

  echo -n "$arn: "
  if echo "$error" | grep -q "deny in a session policy"; then echo "PUBLIC"
  elif echo "$error" | grep -q "deny in a resource-based policy"; then echo "PRIVATE"
  else echo "NO_POLICY"; fi
done

Usage:

./test-exposure.sh arn:aws:iam::111111111111:role/test-role \
    arn:aws:sqs:us-east-1:222222222222:public-queue \
    arn:aws:sqs:us-east-1:222222222222:private-queue


arn:aws:sqs:us-east-1:222222222222:public-queue: PUBLIC
arn:aws:sqs:us-east-1:222222222222:private-queue: NO_POLICY

Extending to other services is straightforward. Just parse the ARN and call the API that you want to test against.

Takeaways

AWS’s new friendly error messages are a public exposure oracle. Apply a deny-all session policy, and AWS tells you exactly what would have been allowed. No mutations, no guessing, no maintenance burden.

The technique works anywhere resource policies exist and AWS has implemented the new error format: SNS topics, SQS queues, Lambda functions, KMS keys, Secrets Manager secrets, ECR repositories, EventBridge event buses, and more.

It’s one trick, any resource! Know exactly what’s public, without touching what isn’t.

Blog

Learn cloud security with our research blog

X
Stay ahead in cloud security
Sign up for the Plerion newsletter and get:
🔸Expert strategies for securing your cloud
🔸Invitations to exclusive events and workshops
🔸Updates on Plerion’s latest features
🔸Early access to cloud security research
Check - Elements Webflow Library - BRIX Templates
Thanks for joining our newsletter.
Oops! Something went wrong while submitting the form.