Skip to main content

Authenticate a Slack Agent

You're building a Slack bot that calls Willow tools. A mention arrives, and before you can do anything you have to decide which Willow identity the tool calls should run as. This page walks that decision with real requests.

If instead you want Willow's own Slack app to trigger background agents for you, that's already built — see Connect the Willow Agent Slack app. This page is for a Slack app you write yourself.

Step 1: Resolve the initiator to an email

A Slack event tells you who triggered it, but only as an opaque workspace ID:

{
"type": "app_mention",
"user": "U08DSCDTF5Z",
"channel": "C0123456789",
"text": "<@U0BOTBOT> what's the status of ticket 4182?"
}

Willow identifies people by email, so exchange that ID with Slack's users.info:

curl -s "https://slack.com/api/users.info?user=U08DSCDTF5Z" \
-H "Authorization: Bearer xoxb-your-bot-token"
{
"ok": true,
"user": {
"id": "U08DSCDTF5Z",
"team_id": "T0123456789",
"is_bot": false,
"is_restricted": false,
"profile": {
"real_name": "Dana Cohen",
"email": "dana@example.com"
}
}
}

The profile.email field requires the users:read.email bot scope. Without a valid token the call returns {"ok":false,"error":"not_authed"}, so if you're testing by opening the URL in a browser, that response is expected.

Now you have dana@example.com and a decision to make.

Step 2: Choose a route

RouteThe user experiencesTool calls run asChoose it when
ImpersonateNothing — the bot just answersDanaYou trust your own app enough for Willow to treat its word as an admin's.
OTTA one-time DM: "sign in to continue"DanaYou'd rather Dana prove it herself, and you're fine asking once per session.
Background agentNothing — the bot just answersThe agentThe work isn't really on Dana's behalf.
If your backend can mint an IdP token for that email, use auth exchange instead

Once you know it's dana@example.com, a backend that can obtain an Okta or Keycloak JWT for her has a strictly better option than impersonation: send that JWT and let Willow verify it. Same zero-friction experience, but the identity is proven rather than asserted. See Auth Exchange.

Before either user-facing route: verify the event

Both impersonation and OTT turn a Slack event into a claim about a person, so the event itself has to be trustworthy first.

  • Verify Slack's request signature on every event, using the x-slack-signature and x-slack-request-timestamp headers. Without this, anyone who learns your endpoint URL can POST a fake app_mention naming any user ID they like — and with impersonation, that is a direct path to acting as your CEO.
  • Reject guests and outsiders. Check is_restricted and is_ultra_restricted for guest accounts, is_bot for other bots, and compare team_id against your own workspace so Slack Connect users from another organization can't trigger the bot.
  • Ignore your own bot's messages so a reply can't loop back in as a new trigger.

Route 1: Impersonate

Send an API token with the run:impersonate scope and name the user in a header. Willow acts as Dana with no sign-in and no interaction:

curl -X POST https://your-org.mcp-s.com/mcp \
-H "Authorization: Bearer wxt_xxxxx" \
-H "x-user-email: dana@example.com" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

Dana sees only an answer, and the audit log attributes the call to her. This is the best experience available and the largest amount of trust you can hand a piece of your own code.

What you're accepting: your bot has become an authentication system. The token can act as any user in the organization, so the code path from Slack event to x-user-email is now the thing standing between an attacker and every account you have.

Two Slack-specific risks are worth naming:

  • Slack email is only as trustworthy as the workspace. If email isn't managed by your IdP through SSO or SCIM, a user can change their own Slack account email. Confirm your workspace is IdP-managed, or keep your own mapping from Slack user ID to Willow user and don't trust profile.email at request time.
  • Willow can't see any of this. From the gateway's side there is no difference between your carefully verified event and a forged one. Every control lives in your code.

Keep the token server-side, grant it only run:impersonate, and use a token dedicated to this bot so revoking it doesn't take down your other integrations.

Route 2: OTT

If you'd rather not hold that much trust, have Dana sign in — once — and act as her for a bounded window afterward. Your bot mints a token, gets a sign-in URL for it, DMs her the link, and polls until she completes SSO.

# 1. Mint a token and ask for a sign-in URL
OTT=$(openssl rand -hex 32)

curl -X POST https://your-org.mcp-s.com/mcp/generate-auth-url \
-H "Authorization: $OTT"
# { "data": { "url": "https://your-org.connect.example.com/org/auth?token=..." } }

# 2. DM that URL to Dana, then poll. Send the bare token, not accessKey:token.
curl https://your-org.mcp-s.com/mcp/get-access-token \
-H "Authorization: $OTT"
# 401 until she signs in, then { "data": { "userAccessKey": "..." } }

# 3. Call tools as Dana
curl -X POST https://your-org.mcp-s.com/mcp \
-H "Authorization: $USER_ACCESS_KEY:$OTT" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

Store the userAccessKey and token against Dana's Slack user ID and reuse them until they expire — 24 hours by default. After that the next mention starts with another DM. That expiry is the point: access is time-boxed, and a bot compromised later holds credentials that die on their own.

Deliver the link as a direct message, never into the channel. The token binds to whoever completes the sign-in, not to the email you resolved in step 1. If the link lands somewhere others can see it, the first person to click it becomes the identity your bot uses for Dana's Slack account — and the poll response tells you only that somebody signed in.

Because your code proposes the token itself, there is nothing server-issued to persist: no client registration, no refresh token, no redirect endpoint to host. A bot with no database can hold the pair in memory and re-run the DM flow after a restart.

Route 3: Background agent

Sometimes the human who typed the message isn't the right identity at all. A nightly digest, a channel-wide triage bot, or a bot reacting to a GitHub webhook is doing its own work — attributing it to whoever happened to mention it is misleading in the audit log and gives the bot whatever access that person has.

In that case give the bot its own identity:

curl -X POST https://your-org.mcp-s.com/mcp \
-H "Authorization: Bearer <access_key>:<secret>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

A background agent is the right shape when you want Willow to hold the definition — the system prompt, an explicit set of tools, triggers, and session history. A machine user is the leaner option if you only want a credential and would rather grant tools through group membership.

Either way, every Slack user gets identical access, so scope the agent to the least any of them should have. Nothing in a public channel should be able to reach a tool you wouldn't expose to the whole channel.

If this is the route you want, check whether you need to build it at all: Willow's Slack app already listens in channels and starts background agents from triggers.

Putting it together

A reasonable default for a bot serving employees: verify the Slack signature, resolve the email, and use auth exchange if your backend can produce a JWT. If it can't, use OTT — the one-time DM is a small price for not making your Slack app an identity provider. Reserve impersonation for a bot running on infrastructure you'd trust with an admin credential, and use a background agent whenever the work isn't really on anyone's behalf.