Home/Blog

How to Find Unused Cloud Permissions in AWS, Azure and GCP

Find unused roles, keys and permissions in AWS, Azure and GCP with native tools, merge them into one report, and remove access without breaking production.

How to Find Unused Cloud Permissions in AWS, Azure and GCP

To find unused cloud permissions, read usage data, not policies. In AWS, an IAM Access Analyzer unused access analyzer reports unused roles, access keys, passwords and permissions across an organization. In Azure, Microsoft Entra sign-in activity shows which users and service principals still hold roles but no longer sign in. In Google Cloud, the IAM recommender and Policy Intelligence activity analysis show unused roles and idle service accounts. Merge the three into one table of principal, permission, last use and action, then remove access in stages, because a 90-day window misses quarterly jobs, break-glass accounts and DR roles.

Key takeaways

  • AWS and Google Cloud measure use per permission. Azure natively measures use per identity, so its report answers "is this identity alive?", not "is this permission used?".
  • Every tool has a blind spot: AWS action-level data covers management events only, Azure's activity log does not record reads, and Google Cloud's activity data has day granularity.
  • One report across clouds beats three consoles. Principal, cloud, permission, last used, suggested action, owner.
  • Disable or deny before you delete. Deleted identities are restorable only for a limited window, if at all, and a recreated identity is a different one.
  • Unused does not mean unneeded. Tag break-glass, DR and annual-job identities and exclude them deliberately rather than by accident.

Unused vs excessive vs long-lived: three different findings

These three get mixed together in audits and tooling, and they need different fixes.

FindingWhat it measuresEvidence sourceTypical fix
Unused accessAn identity, key or permission with no use in the observation windowAccess logs, last-used timestampsRemove, or disable then remove
Excessive permissionsWhat an identity can do versus what its job needsPolicy analysis plus usageReplace with a narrower role
Long-lived credentialsKeys and secrets that exist for months or yearsCredential metadataRotate, shorten lifetimes, move to federation

Unused access is the cheapest to act on because the proof is already in the logs. It is also how excessive permissions usually get found in practice: a role that uses three services out of forty is excessive, and the unused thirty-seven show up as unused permission findings. Long-lived credentials overlap with both, since an old key nobody uses is the worst kind of all three. Our IAM strategy guide covers the policy design side; this post is the audit procedure.

AWS: Access Analyzer and last-accessed data

Unused access analyzer

IAM Access Analyzer's unused access analyzer continuously checks IAM roles and users and generates four finding types: UnusedIAMRole, UnusedIAMUserAccessKey, UnusedIAMUserPassword and UnusedPermission. Unused permission findings cover every service at the service level and the supported services at the action level. It is a paid feature, billed per IAM role and user analyzed per analyzer each month, so check the pricing page before you turn it on across a large organization.

Create one at the organization level from the management account or the delegated administrator. Unused access findings do not vary by Region, so one analyzer is enough:

aws accessanalyzer create-analyzer \
  --analyzer-name org-unused-90d \
  --type ORGANIZATION_UNUSED_ACCESS \
  --configuration '{
    "unusedAccess": {
      "unusedAccessAge": 90,
      "analysisRule": { "exclusions": [ { "resourceTags": [ { "access-class": "break-glass" } ] } ] }
    }
  }'

unusedAccessAge is the tracking period, from 1 to 365 days. The analyzer only evaluates IAM entities that have existed for the whole period, so a role created last week is invisible to a 90-day analyzer. The exclusion keeps tagged break-glass identities out of the findings on purpose, which beats dismissing them by hand every month.

List active unused permission findings and pull the detail for one:

ARN=$(aws accessanalyzer list-analyzers --type ORGANIZATION_UNUSED_ACCESS \
  --query 'analyzers[0].arn' --output text)
 
aws accessanalyzer list-findings-v2 --analyzer-arn "$ARN" \
  --filter '{"findingType": {"eq": ["UnusedPermission"]}, "status": {"eq": ["ACTIVE"]}}'
 
aws accessanalyzer get-finding-v2 --analyzer-arn "$ARN" --id "$FINDING_ID"

The detail lists each unused service namespace, the unused actions where action-level data exists, and when they were last accessed. For unused permission findings, Access Analyzer can also propose a replacement policy:

aws accessanalyzer generate-finding-recommendation --analyzer-arn "$ARN" --id "$FINDING_ID"
aws accessanalyzer get-finding-recommendation --analyzer-arn "$ARN" --id "$FINDING_ID"

Recommendations are not offered for IAM users in a group, for roles created by IAM Identity Center, or for policies that use NotAction. For Identity Center, fix the permission set rather than the generated AWSReservedSSO_ role.

Service last accessed and role last used

For a single role you want to trim now, the service last accessed report shows when each service, and for supported services each management action, was last used:

JOB=$(aws iam generate-service-last-accessed-details \
  --arn arn:aws:iam::111122223333:role/ci-deployer \
  --granularity ACTION_LEVEL --query JobId --output text)
 
# Services the role is allowed but has never used in the tracking period
aws iam get-service-last-accessed-details --job-id "$JOB" \
  --query 'ServicesLastAccessed[?LastAuthenticated==null].ServiceNamespace'
 
# When the role itself was last assumed (list-roles does not return this)
aws iam get-role --role-name ci-deployer --query 'Role.RoleLastUsed'

Four things to know before trusting it. Service data goes back at least 400 days, but action-level data covers management events only, never data plane calls such as reading an S3 object, and iam:PassRole is not tracked at all. Recent activity can take up to four hours to appear. Attempts count, including denied ones. And only the principal that generated the report can read it, so a scheduled job using role credentials has to generate and fetch in the same session.

Credential report

For IAM users, the credential report is still the quickest view of old keys and passwords:

aws iam generate-credential-report
aws iam get-credential-report --query Content --output text | base64 -d > credential-report.csv

Look at access_key_1_last_used_date, access_key_2_last_used_date and password_last_used. N/A means the key was never used or does not exist; no_information for a password means it was never used since tracking began. A new report can be generated at most every four hours. Any IAM user access key that is unused should be deactivated today; most workloads that still use one should move to roles.

Azure: sign-in activity and stale app credentials

Azure is different in one important way. The Azure activity log records control plane writes, not reads, and keeps them for 90 days. A principal holding Reader or a data role leaves no activity log trail when it uses it, so "no entries" proves nothing. The reliable native signal is whether the identity signs in at all. Microsoft's standalone CIEM product, Entra Permissions Management, was retired in 2025. The Defender CSPM plan in Defender for Cloud includes CIEM recommendations for inactive and overprovisioned identities across Azure, AWS and GCP, and is the native option if you need more than sign-in data.

Users

User sign-in activity needs Microsoft Entra ID P1 or P2 and the AuditLog.Read.All permission. lastSuccessfulSignInDateTime covers interactive and non-interactive sign-ins, which matters for accounts that only use tools:

CUTOFF=$(date -u -d '90 days ago' +%Y-%m-%dT%H:%M:%SZ)   # macOS: date -u -v-90d +...
 
az rest --method get \
  --url 'https://graph.microsoft.com/v1.0/users' \
  --url-parameters '$select=userPrincipalName,accountEnabled,signInActivity' '$top=500' \
  --query "value[?signInActivity.lastSuccessfulSignInDateTime < '$CUTOFF'].{upn:userPrincipalName, enabled:accountEnabled, last:signInActivity.lastSuccessfulSignInDateTime}" \
  --output table

When signInActivity is selected, Graph caps pages at 500 users, so follow @odata.nextLink in a large tenant. Users who never signed in have no signInActivity at all and drop out of that comparison; list them separately.

Service principals and role assignments

Workload identities are where unused Azure access tends to hide. The service principal sign-in activity report, on the Graph beta endpoint, gives the last sign-in per app as client or resource, delegated or app-only. It needs P1 or P2 and a role such as Reports Reader:

TENANT_ID=$(az account show --query tenantId -o tsv)
 
az rest --method get \
  --url 'https://graph.microsoft.com/beta/reports/servicePrincipalSignInActivities' \
  --query 'value' > azure_sp_activity.json
 
az ad sp list --filter "servicePrincipalType eq 'Application'" \
  --query "[?appOwnerOrganizationId=='$TENANT_ID']" > azure_sps.json
 
# What a quiet service principal can still do
az role assignment list --all --assignee "$APP_ID" \
  --query '[].{role:roleDefinitionName, scope:scope}' --output table

Like any az rest call, the activity query returns one page, so follow @odata.nextLink in a large tenant. Join on appId. A service principal missing from the activity report, or with an old lastSignInActivity.lastSignInDateTime, and still holding Owner or Contributor somewhere is the finding that matters. Managed identities have their own sign-in log category, so treat them as a separate pass.

App credentials

Unused apps with live secrets are the long-lived credential problem in Azure form. reports/appCredentialSignInActivities on the same beta endpoint shows when each credential was last used and when it expires, and az ad app list --all exposes passwordCredentials[].endDateTime for every registration. We cover the exact queries, including apps with no owners, in our post on filtering app registrations with az ad app list. If you license Microsoft Entra Workload ID, the built-in "Remove unused applications" recommendation (staleApps) flags apps with no token activity for 90 days and skips apps created in that window.

GCP: Recommender and activity analysis

IAM recommender

The IAM recommender compares the permissions each principal holds with what it used, over an observation period of up to 90 days, and proposes removing or replacing roles. It uses a machine learning model to keep permissions a principal is likely to need soon even if unused in the window, so its suggestions are not purely mechanical. Since 2024, recommendations for anything other than basic roles (Owner, Editor, Viewer) need a Premium or Enterprise activation of Security Command Center; basic role recommendations are available without it.

gcloud recommender recommendations list \
  --project="$PROJECT" --location=global \
  --recommender=google.iam.policy.Recommender \
  --format=json > gcp_recommendations.json

Each recommendation has operationGroups with remove and add operations, and pathFilters naming the member and role. Mark a recommendation as claimed when you start on it, and succeeded or failed when you finish, so its state shows who is handling it:

gcloud recommender recommendations mark-claimed "$REC_ID" \
  --project="$PROJECT" --location=global \
  --recommender=google.iam.policy.Recommender --etag="$ETAG"

Viewing recommendations needs IAM Recommender Viewer (roles/recommender.iamViewer) plus Role Viewer on the resource.

Service account and key activity

Policy Intelligence activity analysis reports when each service account, and each service account key, last authenticated:

gcloud policy-intelligence query-activity \
  --activity-type=serviceAccountLastAuthentication \
  --project="$PROJECT" --limit=unlimited --format=json > gcp_sa_activity.json
 
gcloud policy-intelligence query-activity \
  --activity-type=serviceAccountKeyLastAuthentication \
  --project="$PROJECT" --limit=unlimited --format=json > gcp_key_activity.json
 
gcloud iam service-accounts list --project="$PROJECT" --format=json > gcp_service_accounts.json

It runs per project, so loop over projects for an organization-wide view, and it needs the Activity Analysis Viewer role. Timestamps are reported per day, not per event. Compare against the full service account list: an account with no recent authentication, or none at all, is a candidate. Disable before you delete. A deleted service account can be undeleted for 30 days with its roles intact, but a new account created with the same name is a different identity that inherits nothing, and the old account's bindings linger for up to 60 days.

One report across three clouds

Three consoles produce three backlogs nobody owns. Export each cloud to JSON, then merge into one CSV that owners can sort. The AWS export collects finding details for every active finding:

aws accessanalyzer list-findings-v2 --analyzer-arn "$ARN" \
  --filter '{"status": {"eq": ["ACTIVE"]}}' --query 'findings[].id' --output text |
  tr '\t' '\n' | while read -r id; do
    aws accessanalyzer get-finding-v2 --analyzer-arn "$ARN" --id "$id" --output json
  done | jq -s '.' > aws_unused.json

The Azure and GCP files come from the commands above. The merge script needs Python 3.11 or later and nothing outside the standard library:

#!/usr/bin/env python3
"""Merge AWS, Azure and GCP unused-access exports into one CSV."""
import csv, json, re, sys
from datetime import datetime, timezone
 
NOW = datetime.now(timezone.utc)
 
def load(path):
    try:
        with open(path, encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        return []
 
def idle(ts):
    if not ts:
        return ""
    ts = re.sub(r"(\.\d{6})\d+", r"\1", ts.replace("Z", "+00:00"))  # Graph sends 7 digits
    return (NOW - datetime.fromisoformat(ts)).days
 
rows = []
 
for f in load("aws_unused.json"):                      # AWS Access Analyzer
    who, acct, kind = f["resource"], f["resourceOwnerAccount"], f["findingType"]
    for d in f.get("findingDetails", []):
        if "unusedPermissionDetails" in d:
            p = d["unusedPermissionDetails"]
            rows.append(["aws", who, acct, kind, p["serviceNamespace"], p.get("lastAccessed", ""), "reduce"])
        elif "unusedIamRoleDetails" in d:
            rows.append(["aws", who, acct, kind, "role", d["unusedIamRoleDetails"].get("lastAccessed", ""), "remove"])
        elif "unusedIamUserAccessKeyDetails" in d:
            k = d["unusedIamUserAccessKeyDetails"]
            rows.append(["aws", who, acct, kind, k["accessKeyId"], k.get("lastAccessed", ""), "deactivate key"])
        elif "unusedIamUserPasswordDetails" in d:
            rows.append(["aws", who, acct, kind, "console password",
                         d["unusedIamUserPasswordDetails"].get("lastAccessed", ""), "remove password"])
 
activity = {a["appId"]: (a.get("lastSignInActivity") or {}).get("lastSignInDateTime")
            for a in load("azure_sp_activity.json")}    # Azure sign-ins
for sp in load("azure_sps.json"):
    last = activity.get(sp["appId"])
    rows.append(["azure", sp["displayName"], sp["appId"], "service principal sign-in",
                 "all roles held", last or "", "review" if last else "review, no sign-in seen"])
 
for rec in load("gcp_recommendations.json"):            # GCP IAM recommender
    for group in rec.get("content", {}).get("operationGroups", []):
        for op in group.get("operations", []):
            if op.get("action") == "remove":
                pf = op.get("pathFilters", {})
                rows.append(["gcp", pf.get("/iamPolicy/bindings/*/members/*", ""), op.get("resource", ""),
                             rec.get("recommenderSubtype", ""), pf.get("/iamPolicy/bindings/*/role", ""), "",
                             rec.get("description", "")])
 
seen = {a["activity"]["serviceAccount"]["fullResourceName"].rsplit("/", 1)[-1]:
        a["activity"]["lastAuthenticatedTime"] for a in load("gcp_sa_activity.json")}
for sa in load("gcp_service_accounts.json"):            # GCP service accounts
    last = seen.get(sa["email"])
    rows.append(["gcp", sa["email"], sa.get("projectId", ""), "service account authentication",
                 "all roles held", last or "", "review" if last else "disable first"])
 
out = csv.writer(sys.stdout)
out.writerow(["cloud", "principal", "scope", "finding", "permission_or_role",
              "last_used", "days_idle", "suggested_action"])
for r in rows:
    out.writerow(r[:6] + [idle(r[5])] + r[6:])

Run it as python3 merge_unused.py > unused-access.csv, add an owner column from your tags or CMDB, and sort by days_idle. The output is also evidence: a dated file showing what was found and what was done is exactly what an auditor asks for in an access review, as we describe in our SOC 2 guide for startups.

Removing access without breaking production

A decision table keeps the conversation with owners short:

FindingDefault actionKeep and document when
Unused role, service principal or service accountDisable, wait one business cycle, delete in IaCIt is break-glass, DR or runs a known annual job
Unused services or actions in a role that is usedReplace with the recommended or generated narrower policyA rare runbook needs it; move that to a separate time-bound role
Unused access key, service account key or client secretDeactivate now, delete after 30 daysRarely; a legacy integration with a written migration date
Inactive human user holding rolesDisable sign-in, remove role assignmentsDocumented leave, with an end date
Broad role (Editor, Contributor) mostly unusedReplace with a predefined or custom narrower roleNever permanently; at most time-bound

Then remove in stages, each one reversible:

  1. Confirm the owner and the calendar. Ask whether anything runs quarterly, at year end, during DR tests or audits.
  2. Save the current state. Export the policy or role assignments so rollback is a single command.
  3. Disable or deny, do not delete. AWS: attach the AWSDenyAll managed policy to the role or run aws iam update-access-key --status Inactive. Azure: az ad sp update --id "$APP_ID" --set accountEnabled=false. GCP: gcloud iam service-accounts disable "$SA_EMAIL".
  4. Watch for failures. AccessDenied in CloudTrail, failed service principal sign-ins in Entra, permission denied entries in Cloud Audit Logs, and alerts from the owning team.
  5. Delete through infrastructure as code after a full cycle, so the next apply does not bring it back.

Removing unused access is also the cheapest way to shrink the blast radius that posture tools keep flagging, which is why we treat it as part of the same loop as cloud security posture management.

What goes wrong in production

  • The quarterly job. A 90-day window is roughly one quarter. A job that runs every quarter, slightly late, looks unused on the day you act. Annual jobs never show up at all.
  • Console fixes, Terraform reverts. Remove a binding by hand and the next terraform apply puts it back. Or the reverse: a manual emergency grant gets stripped by an authoritative IAM resource on the next apply.
  • New identities are invisible. Access Analyzer only evaluates entities that existed for the full tracking period, and the IAM recommender needs its own observation window. A role created last month with AdministratorAccess will not appear in either report yet.
  • Reads leave no trace. AWS action-level data covers management events only, and the Azure activity log does not record reads. A data role that only reads looks unused when it is not. Check data access logs before removing data permissions.
  • Last-accessed reports fail in automation. The IAM report belongs to the principal that generated it. A scheduler that generates with one role session and fetches with another gets nothing.
  • Delete is not undo. A recreated GCP service account with the same name has a new identity and none of the old roles. An AWS role recreated with the same name gets a new unique ID, so trust and resource policies that pinned the old one break. Disable first.
  • Break-glass accounts get cleaned up. They are unused by design. If they are not tagged and excluded, someone will eventually remove one, and you find out during the outage.

Making it continuous

A one-off audit decays within a quarter. Make the report a scheduled job instead:

  • Monthly export and diff. Run the exports and merge script from CI on a schedule, compare with last month, and open a ticket per owner for new findings. Access Analyzer marks findings as resolved after the next scan once the access is gone, so tickets can close themselves from the same data.
  • Event-driven for credentials. Access Analyzer publishes findings to EventBridge, so a new unused access key can page someone instead of waiting a month.
  • Expiry by default. The fewer standing grants you create, the less there is to find later. Time-bound admin access, as described in our post on just-in-time admin access across AWS, Azure and GCP, turns most privileged roles into grants that remove themselves.
  • Ownership as a tag. Every role, service principal and service account gets an owner tag at creation. Findings without an owner are the ones that never get fixed.
  • Quarterly human review. Automation finds unused access; people decide about privileged roles that are used but too broad. Keep both on the calendar.

Frequently Asked Questions

How do I find unused IAM permissions in AWS?

Create an IAM Access Analyzer unused access analyzer for your account or organization, then list its findings: unused roles, unused access keys and passwords, and unused service or action permissions inside roles that are in use. For a single role, generate a service last accessed report with action-level granularity. Unused access analysis is a paid feature, charged per IAM role and user analyzed each month.

How do I find inactive service principals and app registrations in Azure?

Use the service principal sign-in activity report, available through the Microsoft Graph beta endpoint reports/servicePrincipalSignInActivities with a Microsoft Entra ID P1 or P2 license. Join it to az ad sp list and az role assignment list to see which inactive identities still hold roles. Check app registration credentials too, since an unused app with a live client secret is a standing risk.

How do I find unused service accounts and roles in GCP?

Run gcloud policy-intelligence query-activity with the serviceAccountLastAuthentication activity type and compare it to gcloud iam service-accounts list: accounts with no recent authentication are candidates. For roles, list IAM recommender recommendations, which compare granted permissions with use over up to 90 days. Recommendations for basic roles are free; most others need a Premium or Enterprise activation of Security Command Center.

What is the difference between unused access and excessive permissions?

Unused access is measured: an identity, key or permission that has not been used during an observation window. Excessive permissions are a judgment about what an identity could do compared with what its job needs, and they can exist even when everything was used once. Unused access findings are the easiest place to start removing excess, because the evidence is in the logs.

Is it safe to remove a permission that has not been used for 90 days?

Usually, but not blindly. Quarterly and annual jobs, disaster recovery roles, break-glass accounts and audit-season tasks can all sit idle for more than 90 days and still be needed. Disable or deny first, watch for failures through at least one full business cycle, keep the old policy for rollback, and delete only after that.

How often should cloud permissions be reviewed?

Run the automated unused access report monthly and route findings to owners as tickets. Review privileged roles and their eligibility at least quarterly, which is the cadence most audit programs expect to see evidence for. Credential findings such as unused access keys or expiring secrets should be handled as soon as they appear.

Need help with cloud IAM?

We run this audit as engineers who also own the systems afterwards, so the goal is less access without a 2 a.m. page from a job nobody remembered. Senior engineers build the reporting, work through the findings with your owners and set up the guardrails that keep it clean, and if we built it, we're on the pager for it.