AI agents are starting to do real work. They read files, write data, and call APIs. This is exciting, but it raises a simple question that we often skip:
Who is the agent in your systems?
If an agent logs in with a human account, or with a shared API key that never expires, you have a problem. You cannot limit what it can do. You cannot see what it did. And if the key leaks, the damage can be big.
I wanted to understand this topic better, so I built a small lab. It is not a product and it is not production-ready. It is a private learning project — I am not publishing the code — but in this article I want to walk you through what I built, how, and what I learned.
The idea
I created two AI agents: reader-agent and writer-agent. Both use Claude as their "brain". Both get exactly the same task list:
- read a file from a Google Cloud Storage bucket
- upload a file
- delete a file
- query a BigQuery table
- insert a row and delete rows
- read and append rows in a shared Google Sheet
- read the project IAM policy (a task that is out of scope for both agents — on purpose)
The only difference between the two agents is their identity and the permissions attached to it. So if one agent succeeds and the other is denied, we know it is the identity layer doing the work — not the agent "being nice".
Before the steps — Terraform builds everything
Nothing in this lab was clicked together in a web console. Everything is code, in two independent Terraform modules, with the state stored in Terraform Cloud:
terraform/okta— the identity provider side. It creates the custom Authorization Server, theagent_roleclaim, and the twoclient_credentialsapplications. Okta is the source of truth for who the agents are.terraform/gcp— the cloud side. It is one module, but I split its resources into two layers on purpose:- Identity layer (free, persistent): the Workload Identity Pool and provider, the two service accounts, and the IAM bindings that connect them. I apply this once and leave it running — it costs nothing.
- Workload layer (billable, ephemeral): the GCS bucket, the BigQuery dataset and table, and the audit log sink. I create this layer only for a demo session (
make up) and destroy it right after (make down).
Why Terraform, and why this split?
- Reproducible. I can tear everything down and rebuild the exact same lab in minutes. No "I forgot which checkbox I clicked".
- Reviewable. Every identity, every role, every binding is visible in a pull request. For an identity lab, that is half of the point: the permissions are the code.
- Cost control. The identity of an agent is free to keep. The resources it works on are not. Separating the two means the lab costs almost nothing between sessions.
The repo at a glance
The repo is private, so let me give you the map instead. It is small on purpose:
ai-agent-identity-lab/
├── terraform/
│ ├── okta/ # identity provider: auth server, agent_role claim, two agent apps
│ └── gcp/ # identity.tf = WIF pool/provider, SAs, bindings (free, persistent)
│ # workload.tf + audit.tf = bucket, BigQuery, audit sink (billable)
├── agent/
│ ├── common/ # the building blocks (see below)
│ ├── reader_agent.py # entry point for reader-agent
│ ├── writer_agent.py # entry point for writer-agent
│ ├── run_demo.py # runs both agents through the same tasks, prints the matrix
│ ├── run_boundary.py # the negative federation tests
│ └── tests/ # unit tests — they mock Okta/GCP/Claude, so no credentials needed
├── dashboard/ # a small live console: run the agents, watch the allow/deny matrix
├── docs/ # architecture, setup guides, cost-control notes
├── Makefile # up / down / demo / boundary / rogue / test
└── .github/workflows/ # CI: terraform fmt+validate and pytest — no cloud credentials in CI
The part I care most about is the separation inside agent/common/:
claude_brain.py— talks to Claude and picks the next action. It never touches a credential.okta_auth.pyandgcp_wif.py— get the Okta token and exchange it through WIF. They never touch business logic.actions.py— executes the chosen action against GCS or BigQuery. It never makes an authorization decision — it only reports what IAM said.
Each file can do exactly one dangerous thing, and no file can do two. It is the same least-privilege idea, applied to the code itself.
Step 1 — Create identities in Okta
Each agent gets its own non-human identity in Okta. Technically, each agent is an OAuth2 application that uses the client_credentials flow. This flow is made for machines — no human user, no password, no browser login.
A custom Authorization Server in Okta adds one small but important thing to every token: a claim called agent_role, with the value reader or writer. This claim is the agent's "job title", and Google Cloud will read it later.
All of this lives in the terraform/okta module — the auth server, the claim, and the two apps are a few resource blocks. terraform apply builds the whole identity provider side in seconds, and terraform destroy rotates the client secrets if I ever want a clean start.
reader-agent and writer-agent apps.
agent-lab Authorization Server, Claims tab — the agent_role claim.Step 2 — Federate into Google Cloud (no keys, ever)
I set one hard rule for this lab: no static service account keys. A JSON key file on disk is exactly the kind of secret that leaks.
Instead, I used Workload Identity Federation (WIF). It works like this:
- Google Cloud is configured to trust my Okta Authorization Server.
- The agent sends its Okta token (a JWT) to Google's Security Token Service.
- Google checks the token — the signature, the issuer, and the audience.
- If everything is correct, Google returns a short-lived token (about 1 hour).
- With that token, the agent impersonates its service account:
sa-reader-agentorsa-writer-agent. Which one? Google decides by reading theagent_roleclaim.
All of this trust setup — the Workload Identity Pool, the Okta provider with its attribute mapping, the two service accounts, and the roles/iam.workloadIdentityUser bindings — is the free identity layer of the terraform/gcp module. It stays applied all the time, because an identity that costs nothing should not be created and destroyed like a temporary resource.
The result: nothing to store, nothing to rotate, and a small damage window if a token ever leaks. And one detail I like a lot: the LLM never sees any credential. The "brain" only picks the next action; a separate piece of Python code holds the federated credential and executes it.
okta-pool.
okta-pool details, with sa-reader-agent and sa-writer-agent as connected service accounts.Step 3 — Assign permissions (least privilege)
Each service account gets only the roles it needs, and only on the exact resources — one bucket and one BigQuery dataset, not the whole project:
| Role grant | reader-agent | writer-agent |
|---|---|---|
| GCS bucket | storage.objectViewer | storage.objectAdmin |
| BigQuery dataset | bigquery.dataViewer | bigquery.dataEditor |
| Google Sheet | Viewer | Editor |
The shared Google Sheet does not use IAM roles at all — it is simply shared with sa-reader-agent as Viewer and with sa-writer-agent as Editor. Same idea, a different permission system. Neither agent can read the project IAM policy; that task stays in the list anyway, because a good test also needs something that both agents must fail.
By the way, you do not have to trust the table above. You can ask BigQuery itself — this query lists the roles of both agents on the dataset, straight from INFORMATION_SCHEMA:
SELECT grantee, privilege_type, object_name, object_type
FROM `ai-agent-identity-lab.region-us.INFORMATION_SCHEMA.OBJECT_PRIVILEGES`
WHERE object_name = 'agent_lab'
AND grantee LIKE '%-agent@%'
ORDER BY grantee;
It returns exactly two rows: sa-reader-agent with bigquery.dataViewer and sa-writer-agent with bigquery.dataEditor. Nothing more. I like this query because it turns "trust me, it is least privilege" into something you can check with SQL.
Step 4 — Test it
Both agents run the same task list, and the result is an allow/deny matrix. My code never makes an authorization decision — it only tries the action and reports what IAM said:
| Task | reader-agent | writer-agent |
|---|---|---|
| gcs-read | Allow | Allow |
| gcs-write | Deny | Allow |
| gcs-delete | Miss | Allow |
| bq-query | Allow | Allow |
| bq-insert | Deny | Allow |
| bq-delete | Deny | Allow |
| sheet-read | Allow | Allow |
| sheet-append | Deny | Allow |
| iam-policy | Deny | Deny |
Reader reads; writer reads and writes; neither can touch the project IAM policy.
One honest detail in the matrix: reader's gcs-delete shows Miss, not Deny. Its write was denied one step earlier, so its scratch file never existed and there was nothing to delete. The denial happened where it should — at the write.
I also built a small live console, so I can trigger each target separately and watch the colors:
I added tests that go beyond the happy path:
- Boundary tests (
make boundary) — things that must always be denied: a reader token trying to impersonate the writer's service account, a tampered JWT, and a token with the wrong audience. If any of these is allowed, the trust boundary is broken. - Rogue mode (
make rogue) — I prompt the brain to pick the most destructive action it can, to simulate a compromised or prompt-injected agent. IAM still contains it. The blast radius stays exactly as big as the permissions — no bigger. - Audit trail — every action lands in Cloud Audit Logs and is exported to BigQuery, so I can see the same allow/deny story with a SQL query.
sa-reader-agent with status_code 7 (PERMISSION_DENIED), straight from Cloud Audit Logs.
sa-writer-agent doing the same operations with no errors.What I learned
- Treat agents like employees. Own identity, own badge, and only the access needed for the job. "One shared key for all bots" is the same anti-pattern as a shared admin account.
- Short-lived credentials beat key rotation. With federation, there is simply no key to rotate or to leak.
- "The model behaves" is not a security control. Prompts can be injected and models can make mistakes. IAM does not care about any of that — and that is the point.
- This lab does not prevent prompt injection. It only limits the blast radius when it happens. That is a smaller promise, but an honest one.
I am still learning in this space, and I am sure some of my choices can be improved. If you work on non-human identity, agent security, or workload federation, I would really like to hear your feedback — including the critical kind. The repo itself is private, but I am happy to discuss any part of the design.
—A Skynarc learning project on non-human identity.