Consider this scenario: a platform team reviews their credential report and finds an IAM user named research-agent-2 with an access key provisioned 14 months prior, last accessed 11 months ago—and still active. The project that required it shut down long ago, and the person who approved it no longer works there.
This situation, while constructed, reflects a genuine lifecycle management problem. An agent gets deployed for a specific task, the work concludes, and nobody revisits its permissions. When a human employee departs, offboarding procedures kick in automatically. When an agent finishes, it simply leaves behind an open tab. Because AWS IAM user access keys lack automatic expiration, forgotten keys remain valid indefinitely unless explicitly disabled or removed.
OWASP ranked improper offboarding as the top concern in its 2025 Top 10 for non-human identities, recommending that organizations retire unused resources and assign clear ownership to everything else. The challenge lies in discovering what exists in the first place.
The threat landscape moves faster than annual audits. In September 2026, GreyNoise tracked an AI-driven attack campaign targeting PaperCut servers where an attacker progressed from a blank workspace to remote code execution at a real target in under four hours. Once deployed, that campaign compromised at least eleven organizations within 26 seconds. While this particular attack didn't exploit abandoned AWS IAM keys, it demonstrates how rapidly exposed credentials become weaponized once an attacker gains entry, underscoring why sluggish inventory and revocation processes create serious risk.
An agent's access persists beyond its utility unless someone actively manages its lifecycle. Previous coverage addressed assigning ownership at discovery, transferring ownership before departure, and handling cases where the owner itself is an agent. Credentials introduce a related but distinct challenge.
A deployment identity authenticates an agent or its deployment platform to a cloud account. A provisioned credential—a persistent key, secret, or token created by a deployment for another workload—differs fundamentally. The two have separate owners and different revocation paths.
A forgotten VM costs money; a forgotten credential can grant access.
Addressing this requires three components: a query to surface unused keys, a policy to prevent new ones, and a clock to enforce finite lifespans.
1. The query that finds keys no one is using
CloudQuery syncs AWS IAM credential reports into aws_iam_credential_reports when enabled in source configuration. Each report contains up to two access-key slots per user. This ClickHouse-dialect query, compatible with CloudQuery Platform, retrieves the latest synced report for each account, unpacks the key slots into individual rows, and identifies active IAM user keys at least 90 days old that haven't been used for at least 90 days.
WITH latest_reports AS (
SELECT
_cq_platform_id,
arn,
`user` AS principal,
access_key1_active,
access_key_1_last_rotated,
access_key_1_last_used_date,
access_key2_active,
access_key_2_last_rotated,
access_key_2_last_used_date
FROM aws_iam_credential_reports
ORDER BY _cq_sync_group_id DESC, _cq_sync_time DESC
LIMIT 1 BY _cq_platform_id
),
keys AS (
SELECT _cq_platform_id, arn, principal,
1 AS key_slot,
access_key1_active AS active,
access_key_1_last_rotated AS created,
access_key_1_last_used_date AS last_used
FROM latest_reports
UNION ALL
SELECT _cq_platform_id, arn, principal,
2 AS key_slot,
access_key2_active AS active,
access_key_2_last_rotated AS created,
access_key_2_last_used_date AS last_used
FROM latest_reports
)
SELECT _cq_platform_id, arn, principal,
key_slot, created, last_used
FROM keys
WHERE principal != ''
AND active = true
AND created < now() - INTERVAL 90 DAY
AND (
last_used IS NULL
OR last_used < now() - INTERVAL 90 DAY
)
ORDER BY last_used NULLS FIRST, principal, key_slot;
Note that access_key1_active and access_key2_active lack underscores before the digit, while date columns include them. On Platform, table names reference the latest complete snapshot, so the latest-row clause matters on systems that retain history—it can also surface users deleted since the last sync.
The 90-day threshold serves as a review trigger, not a final determination. Quarterly batch jobs may have legitimately quiet keys, and a null or placeholder last-used date warrants investigation rather than immediate action. The report itself doesn't identify which agent used an account; correlate findings with audit logs and documented ownership. The query excludes the root account, which requires separate urgent review; AWS advises maintaining zero root access keys.
For PostgreSQL deployments, quote user with double quotes, use interval '90 days', and replace LIMIT 1 BY with DISTINCT ON. The ARN uniquely identifies each IAM user:
WITH latest_reports AS (
SELECT DISTINCT ON (arn)
arn, "user" AS principal,
access_key1_active, access_key_1_last_rotated, access_key_1_last_used_date,
access_key2_active, access_key_2_last_rotated, access_key_2_last_used_date
FROM aws_iam_credential_reports
ORDER BY arn, _cq_sync_time DESC
),
keys AS (
SELECT arn, principal, 1 AS key_slot,
access_key1_active AS active,
access_key_1_last_rotated AS created,
access_key_1_last_used_date AS last_used
FROM latest_reports
UNION ALL
SELECT arn, principal, 2 AS key_slot,
access_key2_active AS active,
access_key_2_last_rotated AS created,
access_key_2_last_used_date AS last_used
FROM latest_reports
)
SELECT split_part(arn, ':', 5) AS account_id, arn, principal,
key_slot, created, last_used
FROM keys
WHERE principal != ''
AND active
AND created < now() - interval '90 days'
AND (last_used IS NULL OR last_used < now() - interval '90 days')
ORDER BY last_used NULLS FIRST, principal, key_slot;
The output becomes a prioritized review list. The critical questions follow: who owns this credential, what systems depend on it, and when should access terminate?
Other cloud providers present similar inventory challenges. In Entra ID, entraid_serviceprincipals contains credential metadata while entraid_serviceprincipal_owners shows accountability; examine identifiers and validity dates rather than secret values, since Microsoft Graph returns secretText only at creation time. On Google Cloud, gcp_iam_service_account_keys inventories service-account keys when enabled, but inventory alone doesn't reveal usage patterns; consult Google Cloud's audit logs for that information.
2. The policy that keeps agents from minting keys
The query addresses existing credentials. An env zero approval policy can block certain new keys at deployment time, but only within environments governed by env zero. Policies enforce at deploy and destroy phases, not for credentials created through a console, external script, or env zero task, and they don't revoke the identity authenticating the deployment itself. Cloud-native IAM controls—such as AWS service control policies or GCP's organization policy for service account keys—combined with the inventory approach still perform that function.
Assign the policy to a project, and every environment beneath it, including subprojects, inherits it. The policy doesn't need to distinguish between human and agent deployments; it simply refuses selected long-lived credential types in Terraform and OpenTofu plans. Federation offers a better path where feasible: env zero supports OIDC-based deployment authentication, and AWS issues temporary credentials expiring with the session, though the role itself persists.
package env0
import future.keywords.contains
import future.keywords.every
import future.keywords.if
import future.keywords.in
key_resources := {
"aws_iam_access_key",
"aws_iam_user_login_profile",
"aws_iam_service_specific_credential",
"aws_iam_signing_certificate",
"google_service_account_key",
"azuread_service_principal_password",
"azuread_application_password",
"azuread_service_principal_certificate",
"azuread_application_certificate",
}
# A real Terraform or OpenTofu plan carries format_version, and omits
# resource_changes entirely when there is nothing to change.
tf_plan(p) if {
is_object(p)
p.format_version
}
standard_plan if {
not input.plan.is_run_all
tf_plan(input.plan)
}
run_all_plan if {
input.plan.is_run_all == true
is_object(input.plan.modules)
count(input.plan.modules) > 0
every _, m in input.plan.modules {
tf_plan(m)
}
}
resource_changes := object.get(input.plan, "resource_changes", []) if standard_plan
resource_changes := [rc |
some _, m in input.plan.modules
some rc in object.get(m, "resource_changes", [])
] if run_all_plan
deny contains msg if {
not standard_plan
not run_all_plan
msg := "Unsupported plan format for the credential policy"
}
deny contains msg if {
some rc in resource_changes
key_resources[rc.type]
"create" in rc.change.actions
msg := sprintf("%s: long-lived credential prohibited", [rc.address])
}
allow if count(deny) == 0
This policy parses a standard Terraform or OpenTofu plan, or the module map from Terragrunt run-all. A plan with no changes can omit resource_changes, so an empty plan passes validation, while an unrecognized format gets denied rather than silently approved. Attach it only to projects whose templates produce these formats; Pulumi, CloudFormation, and Kubernetes deployments would all be rejected. Replacements include a create action, so they're caught. The resource list shown is illustrative; the policy cannot detect hardcoded secrets or keys generated outside Terraform.
The allow rule matters: with it, anything not denied proceeds without manual review; without it, a policy returning nothing defaults to pending, and every deployment waits for approval. Test with the full env zero input wrapper, not bare terraform show -json output, covering key creation, role-only plans, replacements, run-all, missing plan data, and destroys. Set ENV0_PRINT_POLICY_INPUT=true to log the exact input; ENV0_OPA_VERSION pins the OPA version. The policy runs on env zero's default OPA (0.46.1) and on 1.x, which is why it uses future.keywords imports instead of import rego.v1.
If an integration genuinely requires a persistent key, don't simply create it in another project. Use a documented exception: specify the named owner, the exact workload, least-privilege permissions, a revocation deadline, secure storage, monitoring, and an approval trail. IAM access keys cannot be tagged, so record ownership on the identity itself and in your credential inventory.
3. Make sure you give every key a finite lifespan
AWS doesn't assign expiration dates to IAM user access keys, so the end date must come from external controls. For Terraform-managed credentials, an environment TTL provides a teardown trigger: place disposable credentials in the same environment as their workload and set a TTL. However, a TTL isn't credential expiry.
It initiates a destroy, and the key vanishes only if Terraform manages it and the destroy completes successfully. A failed destroy, a locked environment, a manually created key, or an identity managed elsewhere leaves access intact, so verify actual revocation rather than relying on the countdown. TTLs can be extended or set to infinite, and TTL limits don't apply to admins. On the Free plan, once an organization exceeds 250 runs monthly, TTLs are destroyed; wait for the counter to reset at the start of the next month (UTC), meaning a key can outlive its TTL by up to a month.
Govern the trust relationship and permission scope of any OIDC federation, and rotate persistent tokens in the issuing system; destroying infrastructure doesn't revoke the caller. Terraform-managed access keys also store their secret values in state, so secure the state backend.
For keys outside any environment lifecycle, retire them deliberately. The credential report omits key IDs, so look those up separately, verify last-used metadata, confirm the owner and any dependencies, then deactivate the specific key:
aws iam list-access-keys \ --user-name research-agent-2 aws iam get-access-key-last-used \ --access-key-id aws iam update-access-key \ --user-name research-agent-2 \ --access-key-id \ --status Inactive
Deactivation is reversible, but it carries risk: a quarterly batch job or disaster-recovery process can fail long after a typical release cycle completes. Monitor for authentication errors during an observation window matched to the workload's schedule, obtain owner approval, then delete through your change management process, since deletion cannot be undone.
env zero's Free plan includes custom approval policies and environment TTL, supporting 250 runs monthly and 30 active environments. CloudQuery's Platform offers a free tier, and its CLI syncs to your own Postgres instance. Sync the credential report, run the query, and count the results. It's read-only, so the worst outcome is a list you didn't expect to see.
A credential needs an end date
An unowned resource drains budget until someone notices. An unowned credential can grant unintended access to whoever discovers it.
An old key doesn't need to be labeled orphaned for an attacker to use it; it only needs to be valid.
Agents that completed last quarter's work won't flag their own credentials, and the next agent encountering one might not belong to your organization. A stale key requires no orphan designation to become useful to an attacker—it only needs to remain active.