Your AI agent in Slack: the scopes you should refuse to request
The install screen is the only security review most Slack agents ever get, and it is generated from your scope list rather than your intentions. What to refuse, why the narrow path ships faster, and the 2025 rate limit change that quietly killed reading a channel's past.
The install screen is the only security review most Slack agents ever get. An admin reads six lines of plain English, decides it sounds reasonable, and clicks Allow. Whatever was on that screen is what your agent is trusted with for the next three years, in a workspace where people discuss salaries, customer escalations, incidents, legal advice and each other.
That screen is generated from your scope list. Not from your code, your prompt, your good intentions or your security questionnaire. Which makes the scope list the security design of a Slack agent, and makes it the first decision rather than the last bit of tidying before you submit. Every tutorial I have read puts channels:history on line one, because it is the shortest path to a working demo. It is also a standing request to read every message in every channel the bot sits in, and it is almost never what the agent actually needs.
So here is the version written backwards. What to refuse, why the narrow path is also the quicker one to ship, and what changed on Slack's side in 2025 that turned least privilege from a principle you agree with into the only architecture that still performs.
- calls to
conversations.historyfor a distributed non Marketplace appwith a hard ceiling of 15 messages per call, down from 1,000 - 1/min
- life of a bot token once you turn on rotationand rotation cannot be turned off again once it is on
- 12h
- how stale a request timestamp can be before you must reject itthe replay window, and the only thing between you and a captured payload
- 5 min
- how long Slack waits for your acknowledgementmiss it and the event is retried, and a retry becomes a second reply
- 3s
Membership is the boundary, scopes are the blast radius
Credit where it is due: Slack's granular permissions model is better than the thing it replaced. A bot token is its own identity rather than a borrowed human one, and channels:history on a bot token reads only the public channels the bot has actually been invited to. The real boundary is membership. Your scope list decides how much of that membership you are able to exploit.
Two things follow from that, and they pull in opposite directions. An agent sitting in three project channels with channels:history is less alarming than the scope name suggests. But nothing stops somebody adding that same bot to the leadership channel on a Tuesday afternoon, at which point the same code begins reading the same way with no deploy, no review and no notification to anybody. Invite rights in most workspaces belong to everybody, so the boundary you are relying on is administered by people who have not read your architecture.
Which is the argument for not asking for history at all.
`app_mentions:read` is the whole trick
The mention scope delivers exactly the messages that address your app, in the conversations it belongs to, and nothing else. No surrounding chatter, no messages between other people, no backfill of what was said before it arrived. For an agent whose job is to answer when asked, that is not a degraded input. It is a complete one.
| Mention driven | History driven | |
|---|---|---|
| Scopes | app_mentions:read, chat:write | channels:history, groups:history, channels:read, chat:write |
| What arrives at your endpoint | Messages that @ mention the app. | Every message posted in every channel the bot is in. |
| What the admin consents to | Viewing messages that directly mention the app. | Viewing messages and other content in the workspace's public channels. |
| Answer to "what did the bot read?" | A finite, loggable list, each entry addressed to it. | Everything in those channels, continuously, for as long as it was installed. |
| Effect of somebody adding the bot to a sensitive channel | It sees nothing until it is spoken to. | It starts ingesting immediately. |
| Rate limited by the 2025 change | No. Events are pushed to you. | Yes, if the app is distributed outside the Marketplace. |
The consent screen difference is not cosmetic, and if you sell into regulated companies it is the difference between an install and a procurement cycle. One line reads like a feature. The other reads like a data processing agreement, and it will be treated as one by whoever has to sign it.
The rate limit that settled the argument
On 29 May 2025, Slack moved conversations.history and conversations.replies from Tier 3 to Tier 1 for apps distributed outside the Marketplace. Tier 3 is 50 or more requests a minute. Tier 1 is one or more. At the same time the limit parameter's maximum and default both dropped to 15 objects, from 1,000.
Read the exemptions properly, because people get this wrong in both directions and then design around the wrong constraint. Internal customer built apps, meaning the single app you build for your own workspace, are not affected and keep Tier 3 with 1,000 objects a call. Marketplace approved apps are not affected either. What got limited is the commercially distributed non Marketplace app, along with new installations of one from that date onward.
So if you are building an agent for your own company, you still have the generous limits and this is a security argument rather than a performance one. If you are building an agent to put into other people's workspaces without going through Marketplace review, bulk history reading is now fifteen messages a minute. That is not a backfill strategy, it is a rounding error, and any roadmap that assumed it could read a channel's past needs rewriting around events instead.
The honest way to read that change is that Slack put a price on the thing you should not have been doing anyway. Agents built mention first barely noticed it happened.
A message is posted in a channel
Thousands a day in a busy workspace, none of your business by default.
Does it mention the app, or is it a DM to the app?
↳ no? then with mention scopes alone it never reaches your endpoint
Verify the signature against the raw body before parsing
Reject on mismatch and alert. There are only two causes, and both are urgent.
Acknowledge with a 200 inside three seconds, then queue
Deduplicate on Slack's event_id, never on an id you generate on receipt.
Does the agent need surrounding context to answer this?
↳ no? the mention text is the entire input, and you are done
Fetch the thread it was asked in, not the channel it sits in
conversations.replieson one thread_ts, bounded, cached, logged.Still not enough? Ask in the thread rather than widening the scope
A clarifying question costs one round trip. A history scope costs a security review.
Never: read the channel continuously in case it is useful later
This is the design that gets rate limited, refused by reviewers and leaked in incidents.
Every step before the thread fetch runs on two scopes. Most agents never need a third.
The scopes to refuse, and what to use instead
These are the ones I take out of a manifest before an install request goes anywhere near a customer's admin.
| Scope | What it actually grants | Instead |
|---|---|---|
chat:write.public | Posting into public channels the app has not joined. It removes the membership boundary on the write side, so the agent can address a channel nobody invited it to. | chat:write and an invitation. If an admin wants the agent somewhere, adding it takes four seconds. |
channels:join | The app adds itself to public channels. Combined with a history scope, the agent chooses its own reading list. | Nothing. If you think you need this, you are building a crawler and should say so out loud. |
users:read.email | Every email address in the directory. It also attracts specific attention in Marketplace review, correctly. | users:read for display names, and Slack's user id as your join key. Match on email only at install, once, if you must map to your own accounts. |
files:read | Every file the app can see, which in a channel means every contract, export and screenshot anybody dropped there. | Handle the file in the event that referenced it and fetch that one url. Do not take standing read on the file store. |
search:read | User token only. Searches everything that person can see, across every private channel and DM they belong to. There is no membership boundary left, because the human is already a member of all of it. | Your own index, built from what the agent was legitimately given, with your own permission checks on top. |
im:history blanket | Every direct message to the app. Reasonable for an assistant that works in DMs, indefensible as a default you did not think about. | Keep it if DMs are the product surface, and say so in the install request. Drop it if the agent only works in channels. |
admin.* | Workspace administration: users, conversations, invites. An agent with these can restructure the company's Slack. | Nothing an answering agent does requires this. Separate any genuine admin automation into its own app with its own install and its own audit trail. |
discovery:read | Enterprise Grid eDiscovery. Reads everything in the organisation, including private channels and DMs, with no membership concept at all. | This exists for legal hold and compliance export. It is not an agent scope and requesting it will end the conversation. |
User tokens are a different risk class, not a bigger number
A bot token is an identity you can reason about. A user token acts as the person who approved it, which means it inherits every private channel and every direct message that person is in, with no scope list able to narrow it back down to a subset. Swapping a bot token for a user token is not a quantitative change in exposure, it is a change in kind, and it is the step where a tidy integration becomes the thing in the incident report.
If a feature genuinely needs one, and occasionally one does, then get it per user through its own OAuth flow, store it encrypted, bind it to that person's session, scope the feature to their own content, and revoke it when they are deactivated rather than when somebody remembers. What you must never do is install a user token on an admin or service account so the agent can see everything, which is the most common shortcut in this whole area. The 2026 autonomy breaches were almost all this shape: not a model that went rogue, but an agent holding credentials far wider than its job, doing something ordinary with them.
Verify the request before you believe a word of it
Your endpoint is a public url that accepts JSON claiming to be from Slack. The signature is what makes that claim checkable, and it has to be computed over the raw bytes, because any parse and re-serialise on the way in will change them and fail the comparison. This is thirty lines, it goes in before anything else, and it is the one piece of Slack boilerplate worth writing by hand even when your framework offers to do it for you.
import { createHmac, timingSafeEqual } from "node:crypto";
const FIVE_MINUTES = 60 * 5;
export function verifySlackRequest(raw: string, headers: Headers): boolean {
// Header names are case insensitive. Do not index a plain object by exact case.
const signature = headers.get("x-slack-signature");
const timestamp = headers.get("x-slack-request-timestamp");
if (!signature || !timestamp) return false;
// Reject replays before spending a hash on them.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > FIVE_MINUTES) return false;
const expected =
"v0=" +
createHmac("sha256", process.env.SLACK_SIGNING_SECRET!)
.update(`v0:${timestamp}:${raw}`)
.digest("hex");
// Equal length is a precondition of timingSafeEqual, not an optimisation.
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}
// In a Next.js route handler, read the body exactly once, as text, before JSON.
export async function POST(req: Request) {
const raw = await req.text();
if (!verifySlackRequest(raw, req.headers)) {
// Not a log line. Either somebody is forging requests or a deploy broke
// your raw body handling and you are now dropping every inbound event.
alertOnSignatureMismatch();
return new Response("invalid signature", { status: 401 });
}
const event = JSON.parse(raw);
if (event.type === "url_verification") {
return Response.json({ challenge: event.challenge });
}
// Acknowledge first, work afterwards. Deduplicate on Slack's own event_id.
await enqueue({ eventId: event.event_id, payload: event.event });
return new Response(null, { status: 200 });
}The acknowledgement deadline is three seconds and no model call finishes inside it, so the handler's only job is to verify, write the event down and return. Miss the deadline and Slack retries, the retry runs your agent a second time, and the user gets two answers to one question. Deduplicate on the event_id in the payload rather than on anything you generate when it lands, for the same reason it matters on WhatsApp webhooks: the retry is a different delivery of the same event, and only Slack's id is stable across both.
Tokens expire now, and rotation is a one way door
Token rotation turns a non expiring token into one that lives 43,200 seconds, which is twelve hours. Each refresh returns a new access token and a new refresh token, and revokes the refresh token you just used, so storing one and not the other breaks the app at the next cycle rather than immediately. The part worth reading twice in Slack's documentation is that rotation cannot be switched off once switched on. Enable it when the refresh path is written and tested, not on the afternoon you are reading about it.
For a single workspace app this is one row in a table and a refresh ahead of expiry. For a distributed app it is a token store per installation with its own refresh clock, which is a real piece of infrastructure and the reason multi workspace distribution is a different project from an internal agent, not a configuration flag on the same one.
The reinstall tax, and why the manifest is an architecture document
Adding a scope later is not a deploy. It requires reauthorisation, which means somebody with admin rights has to approve the app again before the new permission exists. Internally that is a message to one person. Across a customer base it is every workspace admin, asynchronously, on their own schedule, while your feature stays broken for every workspace that has not got round to it and your support queue fills with people reporting a bug that is actually a pending consent.
Which is why the manifest deserves the same scrutiny as a database schema. Both are cheap to get right on day one and expensive to change once other people depend on them. This is the one I start from, and most agents ship on it unchanged.
display_information:
name: Support Agent
description: Answers questions it is asked in channels and DMs.
features:
bot_user:
display_name: Support Agent
always_online: false
oauth_config:
scopes:
bot:
- app_mentions:read # messages that address the app, and nothing else
- chat:write # reply where it was asked, no .public variant
- im:history # only because DMs are a supported surface here
- assistant:write # the assistant pane, set status and suggested prompts
- users:read # display names for addressing people correctly
settings:
event_subscriptions:
request_url: https://example.com/api/slack/events
bot_events:
- app_mention
- message.im
- assistant_thread_started
interactivity:
is_enabled: true
request_url: https://example.com/api/slack/interactions
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: trueNote what is not in there. No channels:history, no channels:read, no files:read, no user token block at all. If the agent needs the thread it was asked in, note that conversations.replies requires that same channels:history grant, so the way to have context on these two scopes is to keep the turns you were already pushed rather than to fetch the thread back. That is its own post, because conversation state in a channel is a harder engineering problem than the permissions around it.
If you are using the assistant surface rather than plain channel mentions, assistant:write arrives on its own alongside the assistant_thread_started event, and the pattern stays the same: you are handed the conversation the user opened with the agent, rather than taking a view over conversations they did not.
What to put in front of the security reviewer
Eventually somebody technical and sceptical asks what this thing can see. The install goes through quickly when you have already answered that, and stalls for a quarter when the answer is being assembled under pressure.
- 1The scope list, with a sentence of justification each. Any scope you cannot justify in one sentence is a scope to remove, and that exercise alone usually shortens the list.
- 2The channels it is in, and who controls that. Membership is your real boundary, so name the people who can change it and say whether you want invites restricted.
- 3What leaves the workspace, and to where. Which provider, which region, what is sent, what is retained and for how long. This is the question that actually blocks installs, and it is about your pipeline rather than your scopes.
- 4What is logged. Every inbound event with its user and channel, every tool call with its arguments, every outbound message. If you cannot reconstruct a conversation afterwards, you cannot answer a complaint about it.
- 5The injection boundary. A message in a channel is untrusted input and can contain instructions aimed at your agent. The content of a channel is never authority to act, which is the same rule as page content in a website agent.
- 6Which actions need a human. Refunds, access grants, anything that writes to another system. Bound the agent so it stops at the edge of what it may do on its own, the way an agent that finishes the job does.
- 7How you revoke it. Who can uninstall, how fast tokens die, and what happens to data already copied out. Have a real answer rather than an aspiration.
Nobody has ever reviewed my prompt. Every install has been reviewed on the scope list, because that is the only part of the design Slack shows the person clicking Allow.
Both AWS and Salesforce have written the setup guide for this, and both wrote it for their own platform, which is fine and also why neither covers the decision that actually matters. The scopes are not a configuration step on the way to the interesting work. In a workspace carrying a company's unguarded conversation, they are the interesting work, and an agent that reads only what it was handed is both the safer build and, since the 2025 limits, the only one that scales past fifteen messages a minute.
Start at two scopes. Add a third when a feature cannot work without it and you can say which feature, in writing, to the person who has to approve it. That single habit removes most of the risk, most of the review friction and most of the rate limiting from a Slack integration, which is a good return on a decision that costs nothing to make early and a reinstall across your whole customer base to make late. The channel by channel view of how this compares with WhatsApp, Teams and Jira is in AI agent integration, channel by channel.
If you have a Slack agent going into a workspace where the conversations are sensitive, or one already installed on scopes nobody has audited since the demo, tell me what it has to do and I will come back with a scope list, the architecture that justifies it and a fixed price. Start that conversation here, or see what this sort of work looks like on the AI capabilities page.
Questions people ask
How do I integrate an AI agent with Slack securely?
Build it mention first: request app_mentions:read and chat:write, so the agent only receives messages that address it and replies where it was asked. Verify each request signature over the raw body, acknowledge inside three seconds, then queue the model call. Avoid channels:history unless a specific feature needs it, because it lets the bot read every message in every channel it is added to, and anyone with invite rights can add it to a sensitive channel without a deploy.
Which Slack scopes should an AI agent avoid requesting?
Avoid chat:write.public, channels:join, users:read.email, files:read, search:read, blanket im:history, admin.* and discovery:read unless the product genuinely depends on them. Use chat:write plus an invitation instead of posting anywhere, users:read with Slack user ids instead of emails, and fetch only the file referenced in an event. search:read and discovery:read reach private channels and DMs with no membership boundary at all, so they are not agent scopes.
Does the 2025 Slack rate limit change affect my internal Slack bot?
No. Internal customer built apps for your own workspace, and Marketplace approved apps, keep Tier 3 limits of 50 or more requests a minute and 1,000 objects per call. The change applies to commercially distributed apps outside the Marketplace, where conversations.history and conversations.replies dropped to Tier 1 with a 15 message limit. For those apps, reading a channel's past is no longer a viable strategy, and the agent should be designed around pushed events.
Should a Slack AI agent use a bot token or a user token?
Use a bot token. A user token acts as the person who approved it and inherits every private channel and direct message they belong to, which no scope list can narrow back down. If a feature truly needs one, obtain it per user through its own OAuth flow, encrypt it, scope the feature to that person's content and revoke it on deactivation. Never install a user token on an admin or service account so the agent can see everything.
Building something like this?
I design and ship these systems for clients: retrieval over private data, agents that complete real tasks, and the Laravel platforms underneath them.