Your Agent Needs Its Own Identity, Not Your Password
Most AI agents run as a human: shared logins, root API keys, the founder's browser profile. Here is how I give agents their own principal, scoped credentials, and a kill switch that actually works.
Pavel Duglas
AI Automation & MVP Architect
Every agent demo ends before the interesting part. The agent researches, plans, calls a tool, prints a nice summary. Nobody asks the question that decides whether this thing can ever run unattended in a real company: who is the agent, exactly? Not “which model” - which account. Whose token is it using. What can that token do at 3am when the loop goes weird. Who gets the email when something breaks a terms of service.
I have shipped automations for clients where the answer was “it uses Sergey’s Gmail because Sergey is the one who set it up.” That works right up until Sergey leaves, or the agent sends 400 emails, or you have to explain to a client which of the 12 000 rows in their CRM the bot touched. Identity is not a compliance detail you bolt on later. It is the thing that makes the difference between an agent you can debug and an agent you have to apologize for.
The three patterns I keep finding in client codebases
1. The agent is a human. It logs into SaaS dashboards with a real employee’s credentials, sometimes with the employee’s 2FA seed pasted into a config file. Every action in every audit log says “Sergey did this.” You have permanently destroyed your ability to answer “was that us or the bot?”
2. The root API key in .env. One key with full account scope, injected into the agent process, visible to every tool the agent can call, including the one that executes shell commands. If the agent can read files and make network requests - and most useful agents can - your root key is one prompt injection away from leaving the building.
3. The founder’s browser profile. Very common in browser automation. Someone points the automation at their everyday Chrome profile because the sessions are already there. Now the bot inherits the banking session, the ad account, and the personal Telegram web session. I have seen a script accidentally close a tab that was mid-payment.
All three come from the same shortcut: reusing an identity that already has permissions instead of creating one that has exactly the right permissions.
Step one: give the agent a principal
A principal is just “a thing that can be authenticated and authorized.” Your agent gets its own. Concretely, per project, I create:
- a service account or bot user in every system the agent touches, named so it is obvious in logs:
svc-invoice-agent,bot-lead-enricher,svc-bas-parser-01; - its own email address on a subdomain if signups are required:
svc-invoice-agent@bots.clientdomain.com; - its own credential set, never shared between two agents, never shared with a human.
The naming convention matters more than it sounds. Six months in, when the client asks why 200 Notion pages changed on a Tuesday, you want to grep one string and be done. Shared identities turn a five-minute answer into a two-day forensic exercise.
Rule of thumb I use: one principal per agent per environment. Staging agent and production agent are different identities. If they share a key, your staging bug is a production incident.
Step two: a credential broker instead of environment variables
Once the agent has an identity, stop handing it long-lived secrets. The agent process should never see a token it does not currently need.
I put a small broker between the agent and the secret store. The agent asks for a capability, not a secret. The broker decides whether that capability is allowed right now, mints or fetches a narrow credential, records the request, and returns something short-lived.
# broker.py - the only module that can read the vault
import time, uuid, logging
CAPABILITIES = {
"sheets.append": {"secret": "GS_WRITER", "ttl": 300, "max_per_hour": 200},
"crm.read": {"secret": "CRM_READONLY", "ttl": 900, "max_per_hour": 500},
"crm.write": {"secret": "CRM_WRITER", "ttl": 120, "max_per_hour": 50,
"requires_approval": True},
"email.send": {"secret": "SMTP_BOT", "ttl": 120, "max_per_hour": 30},
}
def lease(principal: str, capability: str, reason: str, run_id: str):
spec = CAPABILITIES.get(capability)
if not spec:
raise PermissionError(f"{principal} asked for unknown capability {capability}")
if not policy_allows(principal, capability):
raise PermissionError(f"{principal} not entitled to {capability}")
if over_quota(principal, capability, spec["max_per_hour"]):
raise PermissionError(f"quota exceeded: {capability}")
if spec.get("requires_approval") and not approval_granted(run_id, capability):
raise PermissionError(f"human approval required for {capability}")
lease_id = str(uuid.uuid4())
logging.info({"lease": lease_id, "principal": principal, "cap": capability,
"reason": reason, "run_id": run_id, "ts": time.time()})
return {"lease_id": lease_id,
"token": vault_read(spec["secret"], ttl=spec["ttl"]),
"expires_at": time.time() + spec["ttl"]}
Three things this buys you immediately.
Reason strings. The agent has to say why it wants the credential. That string lands in your log next to the run id. When you review a week of runs you can read the agent’s intent, not just its side effects.
Quotas per capability. A runaway loop hits a wall at 30 emails instead of 3000. This is the cheapest blast radius control that exists and almost nobody implements it.
Approval gates as data. “CRM writes need a human” is a line in a dict, not a code path someone can forget to add to a new tool.
If you use a real secret manager, replace vault_read with dynamic secrets that expire on their own. If you are on a small stack, encrypted file plus in-process cache is still miles better than a flat .env mounted into the agent container.
Step three: write down what identity is allowed to do
Scopes should live in a file you can read out loud to a client. I keep one YAML per agent, checked into the repo, reviewed like code:
principal: svc-invoice-agent
environment: production
owner: pavel@example.com
allow:
- crm.read
- sheets.append
- email.send:
recipients: ["*@clientdomain.com"]
deny:
- crm.delete
- billing.*
limits:
runs_per_day: 48
spend_usd_per_day: 6
kill_switch: flags/svc-invoice-agent.enabled
Notice email.send is scoped to internal recipients. This one line prevents the single most embarrassing agent failure mode: a half-finished draft going to a customer. If the agent must email outside the company, that becomes a separate capability with approval required.
Step four: the audit record that makes it worth the effort
Identity without a trail is theatre. For each run I persist one append-only record. Not model chatter - the decisions and effects:
{
"run_id": "2026-02-11T09:14:02Z-8f2a",
"principal": "svc-invoice-agent",
"trigger": {"type": "cron", "schedule": "0 9 * * 1-5"},
"model": "claude-sonnet-4.5",
"prompt_hash": "sha256:19c4...",
"leases": [
{"cap": "crm.read", "reason": "fetch unpaid invoices", "calls": 3},
{"cap": "email.send", "reason": "reminder to finance", "calls": 1}
],
"effects": [
{"system": "sheets", "op": "append", "rows": 12, "idempotency_key": "inv-2026-02-w6"}
],
"cost_usd": 0.41,
"outcome": "ok"
}
The prompt_hash field is the underrated one. When behaviour changes on a Thursday and nobody deployed code, the hash tells you whether the system prompt or a template changed. I have solved “the bot got dumber” incidents in two minutes with that field alone.
The messy part: systems that will not give a bot an account
This is where honest engineering starts. Plenty of services have no service accounts, no API, and terms that forbid automated access. Your options, in the order I try them:
- Official API with a bot user. Always first choice, even if it costs a paid seat. A seat is cheaper than an incident.
- Human-delegated OAuth. The human authorizes once, the agent holds a refresh token scoped to specific permissions, and the human can revoke it in one click without changing their password. Good middle ground for calendars, mailboxes, storage.
- Browser automation with a dedicated profile. In BAS or Playwright, the agent gets its own profile directory, its own fingerprint, its own proxy, its own cookie jar. Never the operator’s daily browser. Treat the profile as a credential: back it up, rotate it, and be able to burn it.
- Human in the loop for the last mile. The agent prepares, a person clicks. For anything involving payments, signatures, or account creation on platforms that ban bots, this is not a compromise, it is the correct design.
On account creation specifically: if a platform requires a human to accept terms, an agent creating accounts is a business risk, not a technical challenge. I tell clients this before the contract, not after.
Rotation and the kill switch
Two operational rules I never skip.
Rotation must be one command. If rotating an agent credential requires editing three configs and redeploying, it will not happen. make rotate PRINCIPAL=svc-invoice-agent that mints new secrets, updates the vault, and restarts the worker. Test it once a quarter on purpose.
The kill switch must be outside the agent. A flag in a file, a row in a table, an env var read at the start of each loop iteration. The broker checks it before every lease. If the flag is off, no credential is issued and the run exits with outcome: disabled. Not “stop the container” - a stopped container loses the audit record and comes back on the next deploy.
A one-afternoon migration path
If you already have agents running on human credentials, do it in this order:
- List every credential the agent currently reaches. Include browser profiles and webhook URLs.
- Create one bot principal per agent in the systems that support it. Add a distinctive name.
- Move secrets behind a broker function. Even a 40-line one. Delete them from the agent’s environment.
- Write the scopes file. Argue about it with whoever owns the data.
- Add the run record and the kill switch flag.
- Revoke the old human credential and run the agent. Whatever breaks was a permission you never intended to grant.
That last step is the real test. When I do this on an existing project, something always breaks - and it is always something the agent had no business doing.
FAQ
Is a separate bot account worth an extra paid seat for a small automation?
Almost always yes. One paid seat is typically 10-30 dollars a month. The alternative is an audit log where you cannot distinguish human from bot actions, and a credential you cannot revoke without locking a person out of their own tools. If budget is genuinely blocking, use human-delegated OAuth with narrow scopes as the interim step, and keep a note in the repo that this is temporary.
Does a credential broker not just move the secret problem one layer down?
It concentrates it, which is the point. Instead of secrets spread across agent processes, tool wrappers, and prompt-reachable context, one small module reads the vault and hands out short-lived, scoped tokens. That module is easy to review, easy to test, and it is where you attach quotas, approval gates, and the audit log. Prompt injection can now request a capability, but it cannot exfiltrate a long-lived root key that was never in the process.
How does this apply to browser automation where there is no API at all?
Treat the browser profile as the credential. Each automation gets its own profile directory, fingerprint, proxy, and cookie jar, provisioned and stored the same way you store API keys, and never the operator's personal browser. Log which profile ran which task with a run id, keep a per-profile action quota, and make sure you can destroy and re-provision a profile in one command. For anything requiring a signature, payment, or account signup on a platform that forbids bots, keep a human on the final click.