A fintech with 140 AWS accounts under a single AWS Organization just failed a customer security questionnaire on one line: “Do you have continuous, agentless posture management across all cloud accounts?” The honest answer was no — three accounts had Wiz, the rest were a spreadsheet and good intentions. The mandate from the CISO is blunt: every account onboarded to Wiz CSPM by quarter-end, new accounts auto-enrolled the day they are created, no per-account click-ops, and the noise tuned so the on-call engineer trusts the queue instead of muting it. This guide is the runbook to do exactly that — onboard the whole Organization through the Wiz AWS connector with a single organization-wide CloudFormation StackSet, then tune risk rules and security-graph policies so the findings are signal, not a wall of red.
The goal is one connector that scans the entire Organization agentlessly (no sensors to install on workloads for posture), discovers every resource into the Wiz Security Graph, and surfaces toxic combinations — the public-exposure-plus-IAM-path-plus-sensitive-data chains that are the real risk — rather than ten thousand isolated config flags. We will keep humans out of the IAM-key business entirely.
Prerequisites
- An AWS Organization with
all featuresenabled and access to the management account (or a delegated administrator account for CloudFormation StackSets). - Trusted access for CloudFormation StackSets enabled in the Organization (
aws organizations enable-aws-service-access --service-principal member.org.stacksets.cloudformation.amazonaws.com). - A Wiz tenant and a Wiz user with the
AdminorConnector Adminrole; the Wiz region your tenant lives in (e.g.us17,eu1). - Terraform >= 1.6 and the AWS provider
>= 5.x, run from a CI runner (we use GitHub Actions with OIDC; Jenkins works identically). - Okta (or Microsoft Entra ID) as your IdP for Wiz SSO and a HashiCorp Vault instance for any residual secrets.
- A ServiceNow instance if you want findings to become tickets (optional but recommended).
- AWS CLI v2 authenticated to the management account with
AdministratorAccessfor the bootstrap only.
Target topology
The end state is one Wiz AWS connector registered against the Organization. The connector assumes a single IAM role — WizAccess-Role — that exists in every member account, provisioned by a CloudFormation StackSet with auto-deployment so new accounts get the role automatically. Wiz uses that role to run agentless workload scanning (it snapshots EBS volumes in your account and analyzes them out-of-band) and to read the AWS control plane via describe/list calls. Everything it discovers — accounts, VPCs, security groups, IAM roles, S3 buckets, EKS clusters, secrets, exposed data — lands as nodes and edges in the Wiz Security Graph, where risk rules and graph policies evaluate paths, not points.
Around that core: Okta/Entra ID federates engineers into the Wiz console via SAML so access is your existing SSO and conditional-access posture (no local Wiz passwords). HashiCorp Vault holds the Wiz service-account clientId/clientSecret used by automation, leased short-lived rather than baked into CI. GitHub Actions / Jenkins apply the Terraform and run Wiz Code (IaC scanning) on pull requests so a misconfiguration is caught before it becomes a runtime finding. ServiceNow receives high-severity issues as change/incident records. Optionally, CrowdStrike Falcon provides runtime threat detection on the same workloads and shares signals with Wiz so a posture risk and an active runtime detection correlate; Dynatrace or Datadog ingests the Wiz issue stream for security-ops dashboards alongside performance telemetry.
1. Bootstrap: enable StackSet trusted access in the management account
A single connector for the whole Organization depends on a role landing in every account. CloudFormation StackSets with service-managed permissions is the clean way to do that — it deploys into Organizational Units and auto-deploys to accounts created later. Enable it once from the management account.
# Run as the AWS Organization management account.
aws organizations enable-all-features 2>/dev/null || true # idempotent if already on
# Let CloudFormation StackSets deploy across the Organization
aws organizations enable-aws-service-access \
--service-principal member.org.stacksets.cloudformation.amazonaws.com
# Verify trusted access is registered
aws organizations list-aws-service-access-for-organization \
--query "EnabledServicePrincipals[?ServicePrincipal=='member.org.stacksets.cloudformation.amazonaws.com']"
Grab the Organization root ID and the OUs you intend to cover — you will target the root to mean “all accounts”:
aws organizations list-roots --query "Roots[0].Id" --output text # e.g. r-ab12
aws organizations list-organizational-units-for-parent --parent-id r-ab12 \
--query "OrganizationalUnits[].[Id,Name]" --output table
2. Create the Wiz AWS connector and capture its external ID
In Wiz, you create the connector first so it hands you the two values the IAM trust policy must pin: the Wiz AWS principal account that will assume your role, and a per-tenant external ID that defeats the confused-deputy problem. Do it in the console (Settings → Cloud Accounts → Add Account → AWS → AWS Organization) or via the Wiz API. The API path is what you automate:
# Authenticate to the Wiz API with a service account (clientId/clientSecret from Vault).
export WIZ_CLIENT_ID="$(vault kv get -field=clientId secret/wiz/connector-sa)"
export WIZ_CLIENT_SECRET="$(vault kv get -field=clientSecret secret/wiz/connector-sa)"
WIZ_TOKEN=$(curl -s -X POST https://auth.app.wiz.io/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&audience=wiz-api" \
-d "client_id=${WIZ_CLIENT_ID}&client_secret=${WIZ_CLIENT_SECRET}" \
| jq -r '.access_token')
# Read back the values Wiz expects in the role trust policy for your tenant.
# (These are stable per-tenant; the console shows them on the AWS connector screen.)
curl -s -X POST "https://api.${WIZ_REGION}.app.wiz.io/graphql" \
-H "Authorization: Bearer ${WIZ_TOKEN}" -H "Content-Type: application/json" \
-d '{"query":"query { cloudOrganizationProviders(first:1) { nodes { externalId } } }"}' \
| jq .
Record two outputs for the next step:
WIZ_PRINCIPAL_ACCOUNT— the AWS account ID Wiz assumes from (shown on the connector screen; differs by Wiz region).WIZ_EXTERNAL_ID— your tenant’s external ID (e.g.wiz_abc123...).
If you prefer fully declarative setup, the official
wizio/wizTerraform module wraps this connector creation — but reading the external ID once and pinning it is the part you must not skip.
3. Deploy the WizAccess role to every account via StackSet (Terraform)
This is the heart of the rollout. One IAM role, one trust policy pinned to Wiz’s principal + your external ID, deployed org-wide with auto-deployment so day-N accounts self-enroll. The role is read-mostly for posture plus the specific permissions agentless scanning needs (creating/sharing EBS snapshots in-account, KMS describe for encrypted volumes).
# providers: aws (management account), plus a CloudFormation StackSet
variable "wiz_principal_account" { type = string } # from step 2
variable "wiz_external_id" { type = string } # from step 2 (store via Vault, not in VCS)
variable "org_root_id" { type = string } # e.g. r-ab12
resource "aws_cloudformation_stack_set" "wiz_access" {
name = "wiz-access-role"
permission_model = "SERVICE_MANAGED"
capabilities = ["CAPABILITY_NAMED_IAM"]
auto_deployment {
enabled = true # new accounts get the role automatically
retain_stacks_on_account_removal = false
}
parameters = {
WizPrincipalAccount = var.wiz_principal_account
WizExternalId = var.wiz_external_id
}
template_body = file("${path.module}/wiz-access-role.yaml")
}
resource "aws_cloudformation_stack_set_instance" "wiz_access_org" {
stack_set_name = aws_cloudformation_stack_set.wiz_access.name
deployment_targets {
organizational_unit_ids = [var.org_root_id] # target the root = all member accounts
}
region = "us-east-1" # IAM is global; pick one home region for the stack instance
operation_preferences {
failure_tolerance_percentage = 10
max_concurrent_percentage = 25 # stagger across 140 accounts
region_concurrency_type = "PARALLEL"
}
}
The CloudFormation template the StackSet deploys (wiz-access-role.yaml) — note the trust policy pins both the Wiz principal and the external ID, and grants a least-privilege scanning policy. The WizSecurityAudit managed-style permissions read the control plane; the inline WizDiskScanning statement is what agentless volume scanning needs:
AWSTemplateFormatVersion: "2010-09-09"
Description: Wiz CSPM cross-account access role (org-wide StackSet)
Parameters:
WizPrincipalAccount: { Type: String }
WizExternalId: { Type: String, NoEcho: true }
Resources:
WizAccessRole:
Type: AWS::IAM::Role
Properties:
RoleName: WizAccess-Role # identical name in every account
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal: { AWS: !Sub "arn:aws:iam::${WizPrincipalAccount}:root" }
Action: "sts:AssumeRole"
Condition:
StringEquals: { "sts:ExternalId": !Ref WizExternalId }
ManagedPolicyArns:
- arn:aws:iam::aws:policy/SecurityAudit
Policies:
- PolicyName: WizDiskScanning
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- ec2:CreateSnapshot
- ec2:CreateSnapshots
- ec2:CreateTags
- ec2:DescribeSnapshots
- ec2:ModifySnapshotAttribute # share snapshot to Wiz scan account
- ec2:DeleteSnapshot
- kms:DescribeKey
- kms:ReEncryptFrom
Resource: "*"
Apply through CI, never from a laptop:
terraform init && terraform plan -out=wiz.plan && terraform apply wiz.plan
# Watch the StackSet roll out across accounts
aws cloudformation list-stack-instances \
--stack-set-name wiz-access-role \
--query "Summaries[].[Account,Region,Status]" --output table
4. Register the Organization in Wiz against the deployed role
Now point the connector at the role you just spread across the Organization. Wiz needs the role ARN pattern (it is the same WizAccess-Role in every account) and the management account ID so it can enumerate members via the Organizations API. In the console this is one screen; via API it is a mutation:
curl -s -X POST "https://api.${WIZ_REGION}.app.wiz.io/graphql" \
-H "Authorization: Bearer ${WIZ_TOKEN}" -H "Content-Type: application/json" \
-d @- <<'JSON'
{ "query": "mutation CreateAwsOrg($input: CreateCloudOrganizationProviderInput!) { createCloudOrganizationProvider(input:$input){ cloudOrganizationProvider{ id } } }",
"variables": { "input": {
"cloudProvider": "AWS",
"externalId": "REPLACE_WIZ_EXTERNAL_ID",
"organizationId": "REPLACE_AWS_ORG_MGMT_ACCOUNT_ID",
"roleArnTemplate": "arn:aws:iam::{accountId}:role/WizAccess-Role"
} } }
JSON
Give Wiz the management account’s read access to AWS Organizations as well (the same WizAccess-Role in the management account inherits SecurityAudit, which covers organizations:List*/Describe*). Within minutes Wiz begins enumerating accounts and assuming the role in each. Confirm coverage in the console (Settings → Cloud Accounts) — you want the account count to match aws organizations list-accounts | jq '.Accounts | length'.
5. Wire SSO (Okta/Entra) and pull automation secrets from Vault
Before engineers touch the findings, federate access. In Wiz, Settings → Identity Providers → SAML, upload the IdP metadata from Okta (or Entra ID), and map IdP groups to Wiz roles so your sec-eng group lands as Wiz Admin and dev-* groups get scoped read on their own projects. This means access to the posture data follows your existing joiner/mover/leaver and conditional-access controls — there is no separate Wiz password to deprovision.
The Wiz service account used by CI (steps 2–4) should never have its secret in the repo. Store it in HashiCorp Vault and have the pipeline fetch it at run time:
# In GitHub Actions / Jenkins, authenticated to Vault via OIDC/JWT:
export WIZ_CLIENT_ID=$(vault kv get -field=clientId secret/wiz/connector-sa)
export WIZ_CLIENT_SECRET=$(vault kv get -field=clientSecret secret/wiz/connector-sa)
# secrets exist only in the job's memory; nothing is written to disk or VCS
6. Tune risk rules and Security Graph policies
A fresh connector will light up thousands of findings; untuned, the queue gets muted within a week. The job now is to make the queue trustworthy. Three levers, in order.
(a) Set the project structure so risk has business context. Map AWS accounts/OUs to Wiz Projects (e.g. payments-prod, data-platform, sandbox) so severity is weighted by environment — a public S3 bucket in payments-prod outranks the same in sandbox. Projects also scope who sees what via the SSO group mapping from step 5.
(b) Tune the built-in risk rules / Cloud Configuration Rules. Wiz ships hundreds of config rules mapped to CIS AWS / NIST. Do not disable noisy ones blindly — re-scope them. For known-accepted designs, attach a resource exception with an expiry and a justification rather than turning the rule off globally, so the exception is auditable and time-boxed.
© Write Security Graph policies for the toxic combinations you actually fear. This is where Wiz earns its keep. A graph query expresses an attack path — not “this SG is open” but “an internet-exposed EC2 instance with a role that can read a bucket holding PII.” Author these as custom Wiz queries so they become high-severity Issues:
# Wiz Security Graph: internet-exposed compute that can reach sensitive data
MATCH (vm:VirtualMachine)-[:HAS]->(role:AccessRole)-[:CAN_ACCESS]->(b:Bucket)
WHERE vm.exposure = "PUBLIC"
AND b.hasSensitiveData = true
RETURN vm, role, b
Promote that query to a policy so any matching path opens an Issue at CRITICAL. Build the handful that matter for your estate: public-exposure → admin-equivalent IAM; unencrypted volume holding secrets; a *:* role assumable cross-account; an EKS pod with a privileged service account reachable from the internet. Each policy should map to an owner project so the Issue routes to the right team.
(d) Shift left with Wiz Code in CI. Add Wiz Code IaC scanning to the same pipeline that applies the Terraform, so the misconfiguration is blocked on the pull request — the cheapest place to fix it:
# .github/workflows/wiz-code.yml — runs on every PR touching infra/
- name: Wiz Code IaC scan
run: |
wizcli auth --id "$WIZ_CLIENT_ID" --secret "$WIZ_CLIENT_SECRET"
wizcli iac scan --path ./infra --policy "Default IaC policy" \
--tag "repo=$GITHUB_REPOSITORY" --tag "pr=$PR_NUMBER"
7. Route findings to ServiceNow and the SOC tooling
Tuned Issues are only useful if they reach an owner. In Wiz, Settings → Integrations, add the ServiceNow integration and create an Automation Action that opens a ServiceNow incident (or change record) for any Issue at HIGH/CRITICAL, stamped with the project, the graph path, and the remediation steps. Set the rule to re-resolve the ticket when Wiz marks the Issue resolved so the two systems stay in sync.
For security operations, stream the Wiz Issue webhook into Datadog or Dynatrace so posture risk sits on the same pane as runtime telemetry, and correlate with CrowdStrike Falcon runtime detections — a Wiz “public host with admin role” plus a Falcon “process injection on that host” is a page-now event, where either alone might wait for business hours.
Validation
Confirm the rollout end to end, not just that Terraform applied.
# 1) Role exists and is assumable in a sample member account (use account creds or assume-role chain)
aws iam get-role --role-name WizAccess-Role --query "Role.Arn"
# 2) Account coverage in Wiz matches the Organization
EXPECTED=$(aws organizations list-accounts --query "length(Accounts[?Status=='ACTIVE'])")
echo "Active AWS accounts: $EXPECTED — confirm this matches the Wiz connector account count."
# 3) StackSet has no failed instances
aws cloudformation list-stack-instances --stack-set-name wiz-access-role \
--query "Summaries[?Status!='CURRENT'].[Account,Status,StatusReason]" --output table
In the Wiz console, verify: the connector status is Connected (not “partial access”), the inventory shows resources from multiple accounts (filter the Security Graph by Cloud Account), your custom graph policies have run at least once, and a deliberately-broken test resource (e.g. a public S3 bucket in a sandbox account) shows up as an Issue and lands in ServiceNow. That last loop — break something on purpose, watch it flow to a ticket — is the real proof the pipeline works.
Rollback / teardown
Because the whole rollout is one StackSet plus one connector, removal is clean and reversible.
# 1) Remove stack instances from all accounts (Terraform destroys the instance + set)
terraform destroy -target=aws_cloudformation_stack_set_instance.wiz_access_org
terraform destroy -target=aws_cloudformation_stack_set.wiz_access
# 2) If StackSet instances linger, delete them explicitly (service-managed)
aws cloudformation delete-stack-instances \
--stack-set-name wiz-access-role \
--deployment-targets OrganizationalUnitIds=r-ab12 \
--regions us-east-1 --no-retain-stacks
# 3) Delete the connector in Wiz (console: Settings -> Cloud Accounts -> Remove),
# or via API deleteCloudOrganizationProvider(id: ...)
Deleting the connector stops scanning immediately; deleting the StackSet removes WizAccess-Role from every account so no residual trust to Wiz remains. Leave auto_deployment.retain_stacks_on_account_removal = false so an account leaving the Org also sheds the role.
Common pitfalls
- External ID mismatch. If the role’s
sts:ExternalIdcondition does not match your tenant’s external ID exactly, Wiz getsAccessDeniedand the account shows “partial access.” Pin it from step 2 and store it in Vault, not by hand. - Targeting the root OU vs. listing accounts. Service-managed StackSets deploy to OUs, not individual accounts. Target the root ID to cover everything; if your accounts are not in an OU under root, they will be skipped.
- Forgetting the management account. Member-account roles do not give Wiz the Organizations enumeration it needs. Ensure
WizAccess-Role(withSecurityAudit) exists in the management account too. - KMS-encrypted volumes silently unscanned. Agentless scanning needs
kms:DescribeKey/ReEncryptFromon the CMKs; without a key-policy grant, encrypted volumes are skipped and you get false confidence. Add Wiz’s scan principal to the CMK key policies. - Muting instead of scoping. Disabling a noisy rule org-wide hides it in
payments-prodtoo. Use time-boxed, justified exceptions scoped to specific resources/projects instead. - Treating every config flag as a path. The value is the graph. Spend your tuning time on the 5–10 attack-path policies that matter, not on chasing every CIS control to green.
Security notes
The design keeps humans out of long-lived AWS keys entirely: Wiz assumes a role with an external-ID guard (no access keys to leak), and the CI service account’s Wiz secret is leased from Vault, not committed. Console access is Okta/Entra SAML only, so deprovisioning a leaving engineer in the IdP removes their Wiz access too. The WizAccess-Role is read-mostly — the only mutating permissions are the snapshot create/share/delete that agentless scanning requires, scoped by the inline policy; review it against your own threat model and tighten Resource from * to volume ARNs if your compliance bar demands it. Pair Wiz’s posture view with CrowdStrike Falcon for runtime so a misconfiguration and an active exploit correlate rather than living in two silos.
Cost notes
Wiz CSPM is licensed per billable cloud resource / workload, so the dominant cost lever is what you scan, not how often. Onboarding 140 accounts will surface idle and forgotten resources — decommissioning those trims both the bill and the attack surface. Agentless scanning incurs minor AWS charges for the short-lived EBS snapshots Wiz creates and deletes; keep snapshot retention at the Wiz default (cleaned up after analysis) so they do not accumulate. Putting Wiz Code in CI is the cheapest spend of all: a misconfiguration fixed on a pull request never becomes a runtime Issue, a ServiceNow ticket, and an on-call interruption downstream. Right-size by mapping sandbox/ephemeral accounts to a lower-frequency scan schedule and reserving continuous scanning for production projects.