How to Audit an AWS Account with Prowler 5.47: Step-by-Step (2026)

Oct 31, 2026 · 11 min · Ajay Kumar

This guide is for engineers who need a real security audit of an AWS account and want it repeatable rather than a one-off spreadsheet. By the end you will have Prowler installed at a pinned version, a read-only ProwlerScan role in the target account, a first scan with CIS and SOC 2 compliance reports, a way to triage and mute findings, a weekly GitHub Actions job, and a loop that scans every account in an AWS Organization. Everything was checked against Prowler 5.47.0, released 29 October 2026, which I installed locally to confirm flags and check counts, plus AWS CLI 2.39.3. You need Python 3.10 to 3.13 (or Docker), the AWS CLI, jq, and permission to create an IAM role in the account you are auditing. For background on what the scan tends to find and why, read the companion post on the top cloud security misconfigurations in 2026.

A repeatable Prowler audit loop for AWS 1. Install prowler==5.47.0 pipx or Docker 2. Role ProwlerScan read-only + ExtId 3. Scan prowler aws CIS 6.0, SOC 2 4. Triage CSV, OCSF, HTML mutelist 5. Fix critical, high first 6. Schedule weekly CI all accounts rescan weekly; new FAIL findings become tickets Prowler 5.47.0, measured locally: 668 AWS checks, 89 services, 50 AWS compliance frameworks. Exit code 3 means at least one unmuted FAIL. Use -z to ignore it in CI when you only want the report.
The audit loop this guide builds. Check, service and framework counts come from running prowler aws --list-checks, --list-services and --list-compliance on version 5.47.0.

Step 1: Install Prowler 5.47.0 at a pinned version

The installation docs recommend pipx, which keeps Prowler's large dependency tree out of your system Python. Pin the version so a scan next month uses the same checks as the scan today.

# Option A: pipx (recommended). Prowler supports Python >=3.10, <3.14.
pipx install prowler==5.47.0
prowler -v
# Prowler 5.47.0 (You are running the latest version, yay!)

# Option B: a throwaway virtualenv with uv
uv venv --python 3.12 ~/.venvs/prowler
uv pip install --python ~/.venvs/prowler/bin/python prowler==5.47.0

# Option C: Docker, pinned tag instead of latest
docker pull toniblyx/prowler:5.47.0

Homebrew (brew install prowler) also works, but you get whatever version the formula currently points to, which is the opposite of pinned.

Step 2: Create a read-only ProwlerScan role

Prowler needs read access across many services. Per the AWS authentication docs, that means the AWS managed policies SecurityAudit and ViewOnlyAccess, plus a small extra policy for checks those two do not cover. The project ships a CloudFormation template that creates a role named ProwlerScan with all three. Download it from the release tag, not from master:

curl -fsSLo prowler-scan-role.yml \
  https://raw.githubusercontent.com/prowler-cloud/prowler/5.47.0/permissions/templates/cloudformation/prowler-scan-role.yml

# The account whose principal will run Prowler (your security/tooling account).
AUDITOR_ACCOUNT_ID=111122223333
EXTERNAL_ID=$(openssl rand -hex 16)   # store this; you need it for every scan
echo "$EXTERNAL_ID"

# Deploy into the account you want to audit (use that account's credentials).
aws cloudformation deploy \
  --stack-name prowler-scan-role \
  --template-file prowler-scan-role.yml \
  --capabilities CAPABILITY_NAMED_IAM \
  --parameter-overrides \
    ExternalId="$EXTERNAL_ID" \
    AccountId="$AUDITOR_ACCOUNT_ID" \
    IAMPrincipal='role/prowler*'

Read the trust policy before you deploy it. The role trusts arn:aws:iam::<AccountId>:root, but only when the caller passes the matching sts:ExternalId and the caller's ARN matches arn:aws:iam::<AccountId>:<IAMPrincipal>. Two things trip people up:

  • The template's AccountId default is 232136659152, which is Prowler Cloud's account. If you run the CLI yourself, always override it with your own auditor account, or you have just granted a SaaS vendor read access.
  • With IAMPrincipal='role/prowler*', only roles whose names start with prowler in the auditor account can assume it. Name your CI role prowler-gha (Step 7). For a quick test from an SSO session, set it to role/aws-reserved/sso.amazonaws.com/* instead, then tighten it later.

The template's inline policy has three statements in 5.47.0: AllowMoreReadOnly (56 read actions), AllowAPIGatewayReadOnly, and AllowSecurityHubImportFindings, which grants securityhub:BatchImportFindings so --security-hub works. That last one is the only write. Prowler cannot change your resources with this role, which matters in Step 6.

Step 3: Run your first scan

From the auditor account, assume the role and scan. Limiting regions and severities makes the first run fast and the first report readable:

TARGET_ACCOUNT_ID=444455556666
prowler aws \
  --role "arn:aws:iam::${TARGET_ACCOUNT_ID}:role/ProwlerScan" \
  --external-id "$EXTERNAL_ID" \
  --region eu-west-1 us-east-1 \
  --severity critical high \
  --output-directory ./prowler-output
echo "exit code: $?"

Expect a banner, a progress bar per service, then a summary table of PASS, FAIL and MUTED counts by service and severity. The run writes three files by default, named prowler-output-<account>-<timestamp> with .csv, .ocsf.json and .html suffixes. An exit code of 3 means at least one unmuted FAIL, which is normal on a first scan. If you only want the report, add -z (--ignore-exit-code-3).

Drop --region and --severity for the full audit once you trust the setup. By default Prowler skips findings for services you are not using; add --scan-unused-services to include them (for example, the account-level S3 Block Public Access check in an account with no buckets, or Macie where you have no S3 data).

Step 4: Run compliance frameworks (CIS 6.0, SOC 2)

Every Prowler check is mapped to requirements in a set of frameworks. List them and pick the ones your auditors or customers care about:

prowler aws --list-compliance | tail -1
# There are 50 available Compliance Frameworks.
prowler aws --list-compliance | grep -E "cis_|soc2|iso27001|pci"
# - cis_1.4_aws ... - cis_6.0_aws
# - cis_7.0_aws
# - cis_controls_8.1
# - iso27001_2013_aws
# - iso27001_2022_aws
# - pci_3.2.1_aws
# - pci_4.0_aws
# - soc2_aws

# See which checks back each SOC 2 requirement
prowler aws --list-compliance-requirements soc2_aws | less

# Run only the checks mapped to CIS 6.0 and SOC 2
prowler aws \
  --role "arn:aws:iam::${TARGET_ACCOUNT_ID}:role/ProwlerScan" \
  --external-id "$EXTERNAL_ID" \
  --compliance cis_6.0_aws soc2_aws \
  --output-directory ./prowler-output

On 5.47.0, --compliance cis_6.0_aws selects 70 checks and soc2_aws selects 171 (I counted the --list-checks output for each). 5.47.0 also ships cis_7.0_aws; use whichever benchmark version your auditors name. Per-framework CSVs land in ./prowler-output/compliance/, one row per requirement and resource, which is the format auditors usually want. A word of caution: a clean SOC 2 mapping is evidence for some technical controls, not a SOC 2 report. Most of the trust services criteria are about process, which no scanner checks.

Step 5: Read and triage the output

The HTML report is fine for a human skim. For anything else use the OCSF JSON, which is a single JSON array. These jq one-liners cover most triage:

F=$(ls -t prowler-output/*.ocsf.json | head -1)

# Count failed findings by severity
jq '[.[] | select(.status_code=="FAIL" and .status!="Suppressed")] | group_by(.severity)
    | map({(.[0].severity): length}) | add' "$F"
# e.g. {"Critical": 3, "High": 17, "Medium": 40}

# The failing critical and high findings: check ID, region, resource
jq -r '.[] | select(.status_code=="FAIL" and .status!="Suppressed" and (.severity=="Critical" or .severity=="High"))
    | [.severity, .metadata.event_code, .cloud.region, .resources[0].uid] | @tsv' "$F" \
  | sort | uniq

# Explain one finding: title, status detail, remediation link
jq -r '.[] | select(.metadata.event_code=="ec2_instance_imdsv2_enabled" and .status_code=="FAIL")
    | .finding_info.title, .status_detail, (.remediation.references[0] // "")' "$F" | head

Prowler also has a local dashboard that reads the output folder: run prowler dashboard and open http://localhost:11666. Do not use -M sarif for an AWS scan. In 5.47.0 SARIF output is limited to the IaC provider and the CLI exits with an error if you try.

Step 6: Fix the top findings, mute the accepted ones

On most accounts the same handful of checks lead the critical and high list: iam_no_root_access_key, iam_root_hardware_mfa_enabled, s3_account_level_public_access_blocks, ec2_instance_imdsv2_enabled, ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22, iam_rotate_access_key_90_days and cloudtrail_multi_region_enabled. The fixes for the account-wide ones are single commands, run with an admin profile, not the scan role:

# S3 Block Public Access for the whole account (check nothing must stay public first)
aws s3control put-public-access-block --account-id "$TARGET_ACCOUNT_ID" \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

# IMDSv2 as the default for new instances in one region
aws ec2 modify-instance-metadata-defaults --region eu-west-1 \
  --http-tokens required --http-put-response-hop-limit 2

# Existing instance still on IMDSv1: require tokens on it
aws ec2 modify-instance-metadata-options --region eu-west-1 \
  --instance-id i-0123456789abcdef0 --http-tokens required --http-endpoint enabled

The EC2 commands match the AWS IMDS configuration docs. Prowler also has a --fixer flag; prowler aws --list-fixers shows the checks that have one, including s3_account_level_public_access_blocks, ec2_instance_account_imdsv2_enabled, guardduty_is_enabled and cloudtrail_multi_region_enabled. Fixers need write permissions (the S3 one documents s3:PutAccountPublicAccessBlock), so they will fail under ProwlerScan. I prefer making the change in Terraform so it does not drift back.

For risks you have accepted, such as a deliberately public website bucket, use a mutelist rather than deleting findings. Muted findings still appear, marked MUTED, and do not trigger exit code 3:

# mutelist.yaml (format from prowler/config/aws_mutelist_example.yaml in 5.47.0)
Mutelist:
  Accounts:
    "444455556666":
      Checks:
        "s3_bucket_public_access":
          Regions:
            - "*"
          Resources:
            - "www-example-com-site"   # public static site, CloudFront in front
          Description: "Accepted: public website bucket, reviewed 2026-10-31"
    "*":
      Checks:
        "*":
          Regions:
            - "*"
          Resources:
            - "*"
          Tags:
            - "environment=sandbox"     # throwaway sandbox resources

Pass it with --mutelist-file mutelist.yaml. Resource entries are regular expressions, so "logs" mutes every bucket containing that string. Be specific.

Step 7: Schedule a weekly scan in GitHub Actions

Use OIDC so no AWS key ever sits in GitHub. Create an IAM role named prowler-gha in the auditor account that trusts GitHub's OIDC provider for this repository (the GitHub Actions OIDC guide walks through it) and allow it sts:AssumeRole on arn:aws:iam::*:role/ProwlerScan. Then:

# .github/workflows/prowler.yml
name: prowler-weekly
on:
  schedule:
    - cron: "0 6 * * 1"   # Mondays 06:00 UTC
  workflow_dispatch: {}
permissions:
  id-token: write   # OIDC token for AWS
  contents: read
jobs:
  scan:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
        with:
          python-version: "3.12"
      - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
        with:
          role-to-assume: arn:aws:iam::111122223333:role/prowler-gha
          aws-region: eu-west-1
      - run: pip install prowler==5.47.0
      - name: Scan
        env:
          EXTERNAL_ID: ${{ secrets.PROWLER_EXTERNAL_ID }}
        run: |
          prowler aws -b --no-color \
            --role arn:aws:iam::444455556666:role/ProwlerScan \
            --external-id "$EXTERNAL_ID" \
            --compliance cis_6.0_aws \
            --mutelist-file mutelist.yaml \
            --output-directory out -z
          # Fail the job only on unmuted critical findings
          F=$(ls out/*.ocsf.json)
          # Muted findings keep status_code FAIL but carry status "Suppressed"
          CRIT=$(jq '[.[] | select(.status_code=="FAIL" and .status!="Suppressed" and .severity=="Critical")] | length' "$F")
          echo "critical failures: $CRIT"
          test "$CRIT" -eq 0
      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        if: always()
        with:
          name: prowler-report
          path: out/

Actions are pinned to commit SHAs for the versions current as of today. The gate filters out muted findings explicitly: in 5.47.0's OCSF writer a muted finding keeps status_code FAIL and gets status set to Suppressed. If you prefer Prowler's own exit code, run with --severity critical and drop -z, but then you lose the high and medium findings from the report.

Step 8: Scan every account in an AWS Organization

First, get ProwlerScan into every member account. The same template does it with StackSets: deploy it once with DeployStackSet=true and AWSOrganizationalUnitId set to your root (r-xxxx) or an OU, from the management account, or from a delegated administrator with DeployFromDelegatedAdmin=true. Keep AccountId set to your auditor account here too. A service-managed StackSet does not deploy to the management account itself, so the local role (DeployLocalRole, true by default) covers that one. Then loop. This is the pattern from Prowler's multi-account docs, with the external ID added. The docs filter on the account Status field, which AWS scheduled for retirement on 9 September 2026, so the query below uses State:

# Run from the auditor account with permission to read the org account list
ACCOUNTS=$(aws organizations list-accounts \
  --query 'Accounts[?State==`ACTIVE`].Id' --output text)
PARALLEL=3
for id in $ACCOUNTS; do
  test "$(jobs | wc -l)" -ge "$PARALLEL" && wait || true
  {
    prowler aws -b -z \
      --role "arn:aws:iam::${id}:role/ProwlerScan" \
      --external-id "$EXTERNAL_ID" \
      --output-directory "./prowler-output/${id}" \
      > "./prowler-${id}.log" 2>&1
    echo "done ${id}"
  } &
done
wait

--organizations-role takes the ARN of a role in the management account so Prowler can add account names and tags to findings. Keep parallelism modest: each scan makes a lot of API calls and you will hit throttling well before CPU limits.

Troubleshooting

Symptom Likely cause Fix
NoCredentialsError: Unable to locate credentials No credentials in the shell or container aws sts get-caller-identity first; pass --profile, or -e AWS_ACCESS_KEY_ID etc. to Docker
AccessDenied on sts:AssumeRole Wrong external ID, or caller ARN does not match IAMPrincipal Use the exact ExternalId from the stack; name the caller role prowler* or widen IAMPrincipal
Role assumable by an account you do not own AccountId left at the template default (Prowler Cloud) Redeploy with AccountId set to your auditor account
sarif output format is only available for the iac provider -M sarif on an AWS scan Use csv, json-ocsf, json-asff or html
CI job always fails with exit code 3 Any unmuted FAIL returns 3 Add -z and gate on your own severity filter, or mute accepted risks
Many AccessDenied errors in logs for specific services Extra read permissions missing Attach the inline policy from the template (or prowler-additions-policy.json)
Scan very slow or ThrottlingException Large account, too many parallel scans Lower parallelism, use --region, raise --aws-retries-max-attempts
Expected findings missing for an idle region Unused services are skipped by default Add --scan-unused-services
Mutelist entry mutes too much Resource entries are regexes Anchor them, e.g. "^www-example-com-site$"
pip install prowler fails on Python 3.14 Unsupported interpreter Use Python 3.10 to 3.13, or the Docker image

FAQ

Is Prowler free?

The CLI is open source under Apache-2.0 and free to run anywhere. Prowler also sells a hosted product, Prowler Cloud, which the CLI advertises in its banner; you do not need it for anything in this guide.

Does Prowler change anything in my AWS account?

Not with the ProwlerScan role, which has read permissions plus securityhub:BatchImportFindings for sending findings to Security Hub. Changes happen only if you run --fixer with credentials that can write, and I would rather make those changes in Terraform.

Prowler or AWS Security Hub CSPM?

Both run posture checks. Security Hub CSPM is native, continuous and integrates with the new Security Hub, priced per check. Prowler runs anywhere, covers more frameworks out of the box (50 for AWS in 5.47.0) and costs nothing but compute. Many teams run Prowler on a schedule and can also push findings into Security Hub with --security-hub.

How often should I run a Prowler scan?

Weekly is a sensible default for most accounts, plus a run after any large infrastructure change. Daily makes sense if the job only reports new critical findings; otherwise people stop reading it.

When I audit an account, the first thing I check is not the findings but the trust path: who can assume ProwlerScan, with which external ID, and whether the AccountId parameter points at an account I own. Then I sort by severity and look at root keys, public buckets, IMDSv1 and internet-open security groups before anything else, because those are the findings that turn a leaked credential into an incident. Official docs: Prowler documentation, Prowler AWS authentication, Prowler multi-account scanning, Prowler releases and AWS IMDS configuration.

Related: How to Use GitHub Actions OIDC With AWS, GCP and Azure (2026), How to Stop Secrets Leaking Into Git: Gitleaks, Pre-commit, CI (2026) and Secrets Sprawl 2026: 28.65M Leaked Keys and 64% Still Valid Years Later.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related