The Missing Principal in Your Slack Agent

The Missing Principal in Your Slack Agent
Holly Girouard
Holly Girouard Developers
9m read

I ask teams the same question early in every conversation about agents: when an agent does something on behalf of a user, can the audit log tell you that it was the agent, which user it acted for, and what authority it used? Most can’t.

A customer I work with runs close to a thousand home-grown agents. None authenticate to downstream systems as the person who made the request; each carries a flat, static bearer token, so every action traces back to a token and stops there. They didn’t set out to build that architecture. One agent worked, which made the next one easy to justify, and the pattern kept repeating.

Slack makes that pattern easy to repeat because building an agent there takes an afternoon. Wire up the Events API, hand it a token for your systems, and it answers questions in a channel like it belongs there, using the same standing authority for everyone who talks to it. Every request has the same reach, and the audit log records only the bot.

A principal is the identity whose permissions govern a request. In a Slack agent that uses one static token for everyone, the missing principal is the requester.

When someone asks the agent to do something, the agent should use that person’s authority through a credential minted for the request and set to expire within minutes. For scheduled work and other jobs with no person in the loop, the agent should use its own identity. The audit log can then name both identities: “Agent A, acting for User B, read resource C with User B’s access.”

Slack agent identity models

  • An individual agent works for one person at a time, either in a DM or in response to that person’s request in a channel. It acts with that person’s entitlements.

  • A shared channel agent has its own identity and offers the same capability to everyone in the channels it joins. Anthropic’s Claude Tag works this way. In Claude Tag’s agent identity access model, admins set baseline access for the workspace and can override it for individual channels.

  • A hybrid can reach data that some people in the channel can’t. Fetching an answer and deciding who may see it require separate authorization decisions. The reference design authorizes the fetch; its audience check remains unfinished.

Regardless of form, the same 2 questions come up: whose authority does the agent use, and who gets to see the answer? We’ll use the individual agent to work through both.

Access and disclosure require separate decisions. Bind the request to the user before reading, then check the audience before posting.

The reference agent binds each read to the requester. A complete audience check before posting remains an open problem.

1. Assume the model gets fooled

Everyone in a Slack channel can read its messages, bots included. A bot that can also reach another system can carry data and instructions between the two.

I assume the model will eventually mistake untrusted text for an instruction, just as people still fall for phishing. We can control the agent’s reach when that happens by giving each request a token limited to what the person who asked may access.

The thread is the largest untrusted input

The part I worry about most is the thread itself. To give the model the conversation history, the reference agent fetches up to 200 messages from a channel thread and places them before the requester’s message. That context is useful, but anyone in the channel could’ve written it.

For that turn, the agent uses the requester’s credential, so an injected instruction can reach only what the requester may access. Before deploying, limit how much thread history enters the model’s context.

2. Choose the principal for each request

An agent needs its own identity for autonomous work. A nightly reconciliation job should use that identity, while a user-initiated request should run with the requester’s permissions. One agent can do both by choosing the principal for each request.

Map the Slack user to a downstream identity

Slack can prove it sent the event and tell the app which Slack user sent the message. GitHub, Snowflake, and Okta still need a credential they can verify for that person.

Slack’s Events API can deliver events 2 ways:

  • With Socket Mode, Slack sends events over an authenticated WebSocket connection.

  • With HTTP delivery, Slack signs each request, and the app verifies the HMAC-SHA256 signature over the version, timestamp, and raw body.

Neither path includes a portable user credential. Slack OpenID Connect can return an id_token, but only after a user-initiated login; the event that triggers the agent doesn’t include one.

You still have to map each Slack user ID to the right GitHub, Snowflake, or Okta identity. I treat that mapping as an identity boundary because application code decides which downstream principal a Slack ID represents.

In the reference design, the agent keeps its own identity. After a person connects their Keycard account, the app links that account to their Slack user ID. For each user-initiated request, the app uses OAuth 2.0 Token Exchange (RFC 8693) to ask Keycard for a short-lived credential scoped to that person’s access.

Keycard evaluates the agent’s identity, the user’s identity, and the target resource together. If it allows the request, it limits the token to the user’s entitlements and names both identities in the audit event.

A Slack user ID is mapped to the downstream user, then evaluated with the agent and target before a scoped token is issued.

Slack identifies the sender. The app maps that Slack ID to a downstream user before asking Keycard for a short-lived, resource-scoped token.

Agent Baseline v1.0-draft calls this AUT-04, just-in-time credentialing: authorize first, then issue a short-lived credential for one resource. The agent can then read from that resource without storing a long-lived downstream secret in its environment.

If someone hides “retrieve everything and delete it” in a channel today and another person later asks a question that causes the agent to follow it, the request runs under the second person’s authority even though they didn’t write the instruction.

Binding the credential to the person who asked shrinks the blast radius. With standing authority, the planted instruction can reach everything granted to the agent and pull everyone’s records. A user-bound credential limits the request to what that person could already see, and the audit log shows the agent acting for them.

The instruction can still tell the agent to post what it finds. The requester may be allowed to read their own record even when other people in the channel aren’t. Publishing the result therefore needs a separate audience check.

If the requester has no account in the target system, the agent can still use a service account. In that case, Keycard stores per-person entitlements and checks whether the requester may access the target resource before the agent uses the account.

What per-user binding costs

For systems with per-user accounts, each person has to authorize access to every downstream resource before the agent can act for them, so you feel the friction on day one. For service-account systems, someone has to maintain per-person entitlements in Keycard. There are more grants to manage too: one for a shared agent identity, or one for every person-resource pair with per-user binding.

For an individual agent, that extra setup buys a smaller blast radius and an audit log that names the requester. A shared channel agent serves a whole room, so its own identity is usually the right principal.

Build the tool list from the requester’s access

Before each turn, the agent builds the list of tools the model can call by asking Keycard for a resource-specific credential for each configured resource. A successful exchange adds the tool. If Keycard returns interaction_required, the agent offers the requester a link to finish authorization; a denial or other failure leaves the tool out:

for _, candidate := range candidates {
    token, err := broker.Exchange(ctx, userToken, candidate.Resource.Identifier)
    switch {
    case err == nil:
        // This person has access. Offer the tool, carrying their token.
        ready = append(ready, withToken(candidate, token))
    case broker.IsInteractionRequired(err):
        // Connection required. Offer an "authorize" affordance and hide the tool.
        needsAuthorization = append(needsAuthorization, candidate)
    default:
        // Denied or unreachable. Omit the tool for this request.
    }
}

As a result, two people in the same channel can get different tool lists from the same agent without per-channel configuration.

Posting “Connect your Snowflake account” in a shared channel tells everyone that the requester still needs to authorize Snowflake and exposes a link meant for them.

The reference agent sends these authorization prompts with the destination set to auto. When the request comes from a channel, auto opens a DM with reqCtx.UserID:

if destination == "dm" || (destination == "auto" && !reqCtx.IsPrivate) {
    dmID, err := slack.OpenDM(ctx, reqCtx.UserID)
    if err != nil || dmID == "" {
        return toolError("failed to open dm")
    }
    _, err = slack.PostMessage(dmID, consentLink, "")
    return result(err)
}

The authorization tool still exposes channel to the model, so auto is only a default the model can override. Remove that option before relying on authorization prompts to stay private.

The agent also pins Slack destinations to the request context. A non-DM action can write only to reqCtx.ChannelID, the channel where the request began. A DM can go only to reqCtx.UserID, the requester.

Bind approval to the person who clicks

At our own all-hands, a teammate clicked to approve deleting a Linear issue. Keycard checked whether he could delete it and refused the action; the audit log recorded the refusal under his name.

We hadn’t planned it as a demo. Allie on our team had built a security agent on Vercel’s eve framework. It pulled threat data from Socket, GitHub, and Linear into one risk view, with a human approval step in front of anything destructive.

The approval button needs the clicker’s identity too. If the agent treats everyone in a channel as the same principal, any channel member can authorize a destructive action.

When someone clicks, the app runs the same token exchange with that person’s token. The requester and approver can be different people, and the audit trail can name both. eve tracks approval-responder authorization in issue #1021. In the version used for this demo, its approval step pauses the workflow and accepts anyone who responds after the approval appears in the channel.

Two early bugs in the reference agent

The reference agent checks any requested channel against reqCtx.ChannelID and takes the DM recipient from reqCtx.UserID.

In an earlier prototype of the reference agent, we’d tried to constrain both destinations but left the decisive inputs under model control: allow_external could waive the origin-channel check, and the model supplied the DM recipient.

A worse bug was hiding in the consent link: its target Slack user ID sat in an unsigned query parameter, which made the link forgeable.

You could send a colleague a link from the agent’s real domain; when they logged in, their access would bind to your Slack user ID, and the audit trail would name them for everything you did next. The fix signs the whole consent request, including its expiry.

3. Check who gets to see the answer

The reference agent exchanges a token for each read from a connected system. For Slack posts, it calls Slack directly with the install-time bot token and never asks Keycard to authorize the write.

Adding that Keycard check would decide whether the agent may post at all. Before posting to a channel, the app would still have to fetch its current members and verify that each person may see the data. If anyone fails the check, the app could send the result to the requester by DM.

We haven’t built a complete audience check, and I haven’t seen a vendor demonstrate one while people join and leave the channel. If someone says their conversational agent checks its audience, ask to see it work.

Start with identity before the retrofit

If your team is building a Slack agent, I’d give it both principals from day one: its own identity for autonomous work and the requester’s identity for work they asked it to do.

For the customer with close to 1,000 agents, retrofitting later means finding every shared credential, changing every request path, and rebuilding the audit trail.

The reference agent is on GitHub as keycard-slack-agent-demo. About 1,700 lines implement the identity layer: passing agent and user identity through each request, encrypting tokens, discovering resources through Keycard, and handling account linking and token exchange. The 2 excerpts here come from 47 lines of source.

If you want the per-user token flow without the full demo, our Slack-agent tutorial walks through it on its own. If you’re building a chat agent and need help with the identity layer, email us.

Last updated August 13, 2026

Have questions about agent security?

Ask our agent — it's a live Keycard-on-Keycard demo.