What happens when the agent is long gone, but its access key lives on?
Imagine: There’s a line on the credential report that no one on the platform team can explain. An IAM user called research-agent-2 has an access key created 14 months ago that was last used 11 months ago. And it’s still active.
The project it supported closed long ago, and the person who approved it doesn’t even work at the company anymore. While this example is hypothetical, the lifecycle problem isn’t.
This doesn’t require carelessness. An agent is created for a task, the task ends, and nobody revisits its access. A departing person triggers offboarding; a finished agent just leaves a closed tab. AWS IAM user access keys don’t expire automatically, so a forgotten key stays valid until somebody disables or deletes it.
“A departing person triggers offboarding; a finished agent just leaves a closed tab.”
OWASP puts improper offboarding first in its 2025 Top 10 for non-human identities, and the remedy reads like this series’ premise: retire what’s no longer needed, and give the rest an accountable owner. The hard part is knowing what exists.
Why slow revocation is dangerous
The problem is that attackers move faster than an annual audit ever could. In September 2026, GreyNoise documented an AI-orchestrated campaign against PaperCut servers: the attacker went from an empty workspace to remote code execution at a real victim in under four hours, and once launched, the campaign compromised at least eleven organizations in 26 seconds.
That wasn’t an attack using abandoned AWS IAM keys. It shows how quickly exposed access becomes useful once an attacker is in, and why slow inventory and revocation are dangerous.
An agent’s access can outlive its purpose unless someone governs its lifecycle. The last three articles covered attaching an owner at discovery, reassigning one before a last day, and what happens when the owner is an agent. Credentials add a related problem, with one distinction.
An agent’s deployment identity is what authenticates it, or the deployment platform, to a cloud account.
“A forgotten VM costs money; a forgotten credential can grant access.”
A provisioned credential is a persistent key, secret, or token that a deployment creates for some other workload; the two have different owners and revocation procedures. A forgotten VM costs money; a forgotten credential can grant access.
The remedy is still a query, a policy, and a clock, each used for the part of the problem it can solve.
1. The query that finds keys no one is using
CloudQuery can sync AWS IAM credential reports into aws_iam_credential_reports when that table is enabled in the source configuration. The report lists up to two access-key slots per user.
This query, written for the ClickHouse dialect CloudQuery Platform uses, takes the latest synced row for each report, turns the slots into rows, and returns active IAM user keys that are at least 90 days old and unused 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 ' ' AND active AND created 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
The policy reads 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 counts as valid, while an unrecognized shape is denied rather than silently allowed; attach it only to projects whose templates produce these formats, since Pulumi, CloudFormation, and Kubernetes deployments would all be denied.
Replacements include a create action, so they’re caught. The resource list is an example, and the policy can’t see a hardcoded secret or a key generated outside Terraform.
The allow rule matters: with it, anything not denied proceeds without manual approval; without it, a policy that returns nothing defaults to pending, and every deployment waits for a person. 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.
ENV0_PRINT_POLICY_INPUT=true logs 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 needs a persistent key, don’t just create it in another project. Use a documented exception: a named owner, a specific workload, least privilege, a revocation deadline, secure storage, monitoring, and an approval record. IAM access keys can’t be tagged, so record ownership on the identity and in your credential inventory.
3. Make sure you give every key a finite lifespan
AWS doesn’t give IAM user access keys an expiration date, so the end date has to come from somewhere else. For Terraform-managed credentials, an environment TTL gives you a teardown point: put disposable credentials in the same environment as what they serve and set a TTL.
But a TTL isn’t credential expiry. It starts a destroy, and the key disappears only if Terraform manages it and the destroy succeeds. A failed destroy, a locked environment, a manually created key, or an identity managed elsewhere leaves access behind, so verify revocation instead of trusting the countdown.
TTLs can be extended or set to infinite, and TTL limits don’t bind admins. On the Free plan, once an organization passes 250 runs in a month, TTLs are destroyed; wait for the count to reset at the start of the next month (UTC), so 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 record their secret values in state, so protect the state backend.
For keys outside any environment lifecycle, retire them under control. The credential report doesn’t include key IDs, so look those up, check last-used metadata, confirm the owner and dependency, 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 isn’t harmless: a quarterly batch or disaster-recovery job can fail long after a typical release cycle. Watch for authentication errors through an observation period matched to the workload’s schedule, get owner sign-off, then delete under your change procedure, since it isn’t reversible.
env zero’s Free plan includes custom approval policies and environment TTL, with 250 runs a month and 30 active environments. CloudQuery’s Platform has a free tier, and its CLI syncs to your own Postgres. Sync the credential report, run the query, and count the rows. It’s read-only, so the worst outcome is a list you didn’t want to see.
A credential needs an end date
A resource without an owner costs money until someone notices, and an unowned credential can grant unintended access to whoever finds it.
“An old key doesn’t need to be labeled orphaned for an attacker to use it; it only needs to be valid.”
Agents who finished last quarter’s projects won’t flag their own credentials, and the next agent to encounter one might not belong to your organization. An old key doesn’t need to be labeled orphaned for an attacker to use it; it only needs to be valid.
The post What happens when the agent is long gone, but its access key lives on? appeared first on The New Stack.
Comments
No comments yet. Start the discussion.