| published by | Naseeb Ahmed Mian |
|---|---|
| in blog | The New Stack |
| published date | 2026-10-08 |
| original entry | The audit log says my name: what an agent inherits when you hand it your credentials |
I measured what an AI agent actually holds when it authenticates as me. Authentication turned out to be a token whose scope string literally says user_impersonation. Authorization came almost entirely from group membership that standard permission checks cannot see.
Permission scoping differed by two orders of magnitude between two production stores reached an hour apart, and audit coverage ran inversely to what the credential could do. The environment where the agent could delete from a secure database was the only one with no audit trail.
When I measured the machine identity our own unattended agent runs under, built properly and scoped per resource, it broke in the same place mine does.
“The environment where the agent could delete from a secure database was the only one with no audit trail.”
I gave a coding agent my own Azure credentials, which is standard practice, and then investigated what it could reach. The answer: two permissions on one production store and 107 on another. The environment where it could delete from a secure database was the only one with auditing switched off. None of that was a misconfiguration. All of it was reasonable when the only thing holding that credential was a human.
Eleven agent skills in our working repository connect to databases. They all authenticate the same way:
Bash
sqlcmd -S <server> -d <db> --authentication-method ActiveDirectoryAzCli -C -Q "<query>"
That flag tells sqlcmd to ask the Azure CLI for its cached token and present it. No connection string, no stored password, and no credential belonging to the agent itself. When the agent runs a query, the database sees me.
Engineers describe this as the agent “not having an identity yet.” It does have one. It has mine.
“Engineers describe this as the agent ‘not having an identity yet.’ It does have one. It has mine.”
Using borrowed credentials is a known bad practice. It leads to three common outcomes: attribution collapses, the agent inherits whatever permissions the human accumulated, and revoking the agent revokes the human. I wanted to measure these predictions against a live, running estate. The results show that two of those three outcomes are much stranger than they appear on the surface.
First, I checked what the database records about incoming connections:
SQL
SELECT login_name, program_name, host_name, client_interface_name
FROM sys.dm_exec_sessions
WHERE session_id = @@SPID;
Plaintext
login_name: naseeb.ahmed@<redacted>
program_name: sqlcmd
host_name: <my workstation>
client_interface_name: go-mssqldb
These identity fields reflect my login, my machine, and the tool. Not one of them changes depending on whether I typed the query or the agent decided to run it. Because no fourth field exists for an agent to declare itself, agent actions and human actions are identical by design.
Every guardrail downstream of this point relies on these same three values. No downstream control can be conditional on the agent: not a rate limit, not an approval step, and not a different retention policy for machine-initiated statements.
“The record is accurate about the credential, but silent about the intent.”
This creates a reverse problem, too. If someone questions a query I typed six months from now, I have no way to prove it was mine rather than something an automated tool executed. The record is accurate about the credential, but silent about the intent.
Our approach to authentication was simple: nobody actually designed it. A developer signs in once with a directory account and a second factor, the CLI caches the result, every skill in the repository picks it up, and agent authentication simply piggybacks on whatever the human did earlier that morning. It avoids storing passwords and eliminates the need to provision an identity before you know whether the agent is useful.
Decoding the access token the CLI hands over for database connections reveals the following claims:
Plaintext
aud: https://database.windows.net/
appid: <Azure CLI public client ID>
appidacr: 0
scp: user_impersonation
exp - iat: 84 minutes
groups: 37 entries
amr: ['pwd', 'mfa']
Two of these claims stand out:
user_impersonation. RFC 8693 defines impersonation as a principal receiving all rights of another while remaining indistinguishable from it. My session record matches that definition.The 84-minute lifetime looks like a safety boundary, but it is not. The CLI refreshes automatically using its refresh token, so an unattended run does not stop after 84 minutes. It stops only when the refresh token expires or when someone deactivates the user account.
Listing role assignments across every subscription in the tenant returned a single row: Reader on a non-production subscription.
However, group membership tells a different story:
The access-relevant groups mirror an organizational chart:
<environment> - SQL Database Viewer<environment> - SQL Elevated<environment> - Resource Contributor<environment> - SQL Database Manager<environment> - Resource Viewer<environment> - ReadersGroups carry everything that grants actual data access and live in the data plane, where control-plane queries cannot see them. A security reviewer checking an agent’s authority through standard role assignments gets a single row and a false sense of a small blast radius.
The token’s groups claim shows 37 entries, while the directory’s direct-membership query shows 34. The difference comes from nested groups. The two authoritative answers for group membership don’t agree, and the database uses the larger number.
Some of those memberships are managed just-in-time (JIT). Querying which ones are temporary resulted in an error:
Plaintext
Forbidden: PermissionScopeNotGranted
PrivilegedEligibilitySchedule.Read.AzureADGroup, PrivilegedAccess.Read.AzureADGroup
The credential that can delete rows in a secure database cannot read whether its privileges are standing or temporary. Per vendor documentation on JIT group activation, an application that has already cached a membership may keep honoring it after deactivation. The window binds the directory record, but it does not reach into an already issued token.
Using fn_my_permissions to check what the connected principal actually holds on the production database server showed:
The token holds two permissions, and the secure tier refuses the connection outright. Read access is enforced where necessary, with no path to the sensitive tier. The agent inherits a tight, correct scope here.
However, querying the production analytics endpoint over our lakehouse with the same token revealed:
One identity accessed two production stores an hour apart, holding two permissions on one and 107 on the other. A grant that wide is no longer a job description; it is a full role assigned to a human who occasionally looks at analytics, now handed to a tool running thousands of statements unattended.
Vendor documentation notes that the analytics endpoint is read-only over the underlying tables, with modifications passing through a different engine. Security rules configured on that endpoint govern access through it, but they do not follow the data when another engine accesses it. Effective access depends on the path, not the principal.
“Code enforces one restriction, while the other is a sentence in a file. The agent reads both, verifies neither.”
Furthermore, the instruction file the agent reads before touching that endpoint states the surface is read-only. The credential the agent presents grants 107 permissions. Code enforces one restriction, while the other is a sentence in a file. The agent reads both, verifies neither, and operates on an instruction set that contradicts its actual privileges.
In a non-production environment, the scope opens up entirely:
Write and delete rights apply to both tiers. In most software estates, non-production environments allow team members to break things by design.
Checking database-level audit configurations returned six enabled specifications:
SQL
SELECT name, is_state_enabled FROM sys.database_audit_specifications;
While the database reported auditing as enabled, querying the monitoring sink returned an empty result rather than a permission error. Nothing was writing to the log.
Audit configurations live on the server resource, not inside the database. Querying the management API revealed that one environment had server auditing enabled with statement-level action groups attached:
The second environment had auditing completely disabled at the server level, with no destination attached. The six database specification objects were remnants from past environment copies.
The internal database check reported “enabled” for an environment recording zero activity.
Audit coverage runs inversely to what the borrowed credential can do. Production environments are audited, while non-production environments are left unmonitored to save on storage and processing costs.
This logic holds until an agent replaces the human user. The agent removes the core assumption behind both configurations without alerting either system.
Additionally, failed logins under directory authentication do not reach the SQL audit log because credentials are verified before touching the database. The refusal that protected the secure tier remains invisible in the logs. Auditing is also best-effort under heavy load, with statements truncated past 4,000 characters.
Our repository also contains an unattended agent running on a 15-minute timer trigger. Its identity uses a user-assigned managed identity per app group with explicitly named role grants:
This structure represents a properly configured machine identity. However, when connecting to databases, the managed identity is not used. Instead, it authenticates via a standard SQL login and password set at deploy time.
The database cannot distinguish between different callers. The machine identity stops where the data path begins. At the database, the automated agent is just as unattributable as a human user.
Both models fail at the same place. Giving every agent its own service account changes the name in the log, but it does not resolve attribution.
Tightening permissions and turning on auditing in non-production environments limits potential damage, but it does not fix root-cause attribution. The system still logs human credentials for automated actions.
Addressing these issues requires five core changes:
The Model Context Protocol (MCP) authorization specification explicitly prohibits servers from forwarding client tokens to ensure downstream services can verify caller identity. Command-line tools need to adopt the same standard.
These findings represent measurements taken across a single estate over two days:
YOUTUBE.COM/THENEWSTACK
Tech moves fast, don't miss an episode. Subscribe to our YouTube channel to stream all our podcasts, interviews, demos, and more.