If you are building a Slack app that more than one workspace will install, Slack OAuth is the part you cannot fake your way through. A single-workspace bot can paste a token from the app config screen and move on. The moment you go multi-tenant, you own an install flow, a token store, and a rotation schedule, and every one of those has a way to leak credentials or take the bot offline if you get it wrong. We built exactly this for a Vietnamese commercial bank that runs one bot instance across its head office and branch workspaces, under a compliance regime that dictates encryption, audit retention, and rotation windows. This is what that looks like in code.
The OAuth 2.0 install flow, end to end
The flow is a four-hop handshake. You send the user to an install URL, Slack shows them an authorize screen for the scopes you requested, they click Allow, and Slack redirects back to your redirect URL with a one-time code. Your server then POSTs that code to oauth.v2.access along with your client ID and secret, and Slack hands back the bot token plus the team it belongs to.
Before any of this works you configure the app at api.slack.com/apps under "OAuth & Permissions": add your redirect URL, list your bot token scopes (chat:write, commands, app_mentions:read, users:read, channels:history in our case), and decide on a distribution model. Restricted distribution limits installs to workspaces you whitelist by ID, which is what an internal compliance tool wants. Public distribution lets any workspace install but requires Slack review before you reach the App Directory. The bank went restricted and pins a fixed list of allowed workspace IDs under "Manage Distribution"; changing that list requires a security ticket.
Step one is building the install URL. Do not hardcode it into an "Add to Slack" button in static HTML, because every install needs its own state value to defend against CSRF.
const crypto = require('crypto');
function buildInstallUrl(req, res) {
const state = crypto.randomBytes(24).toString('hex');
res.cookie('slack_oauth_state', state, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 10 * 60 * 1000, // 10 minutes
});
const scopes = [
'chat:write', 'commands', 'app_mentions:read',
'users:read', 'channels:history',
].join(',');
const url = new URL('https://slack.com/oauth/v2/authorize');
url.searchParams.set('client_id', process.env.SLACK_CLIENT_ID);
url.searchParams.set('scope', scopes);
url.searchParams.set('state', state);
url.searchParams.set('redirect_uri', process.env.SLACK_REDIRECT_URI);
res.redirect(url.toString());
}
app.get('/oauth/install', buildInstallUrl);
Step two is the callback. Validate state against the cookie before you touch the code, handle the user-denied case, then exchange the code.
const axios = require('axios');
app.get('/oauth/callback', async (req, res) => {
const { code, state, error } = req.query;
if (error) return res.status(400).send(`OAuth denied: ${error}`);
const expected = req.cookies?.slack_oauth_state;
if (!expected || expected !== state) {
return res.status(400).send('Invalid state');
}
res.clearCookie('slack_oauth_state');
if (!code) return res.status(400).send('Missing code');
const resp = await axios.post(
'https://slack.com/api/oauth.v2.access',
new URLSearchParams({
code,
client_id: process.env.SLACK_CLIENT_ID,
client_secret: process.env.SLACK_CLIENT_SECRET,
redirect_uri: process.env.SLACK_REDIRECT_URI,
}).toString(),
{ headers: { 'content-type': 'application/x-www-form-urlencoded' } }
);
if (!resp.data.ok) {
return res.status(500).send(`OAuth failed: ${resp.data.error}`);
}
const data = resp.data;
await saveInstallation({
teamId: data.team.id,
teamName: data.team.name,
botToken: data.access_token,
botUserId: data.bot_user_id,
scope: data.scope,
installedBy: data.authed_user?.id,
userToken: data.authed_user?.access_token,
});
res.redirect('/oauth/success');
});
The success response carries access_token, team, scope, bot_user_id, and, if you requested user scopes, an authed_user object with its own token. The team.id is the key everything else hangs off.
One token per workspace, looked up at runtime
A multi-tenant app keeps exactly one installation per workspace, keyed by team_id with a unique constraint so a reinstall updates the existing row instead of duplicating it. Every inbound Slack request (an event, an action, a slash command) carries the team_id, so handling a request is a matter of looking up that workspace's token and constructing a client.
async function getBotTokenForTeam(teamId) {
const inst = await db.findInstallation({ teamId });
if (!inst) throw new Error(`no installation for team ${teamId}`);
return decrypt(inst.botTokenEnc);
}
async function handleSlashCommand(req, res) {
const token = await getBotTokenForTeam(req.body.team_id);
const slack = new WebClient(token);
// ... use the per-workspace client
}
Storing tokens so an audit does not sink you
Tokens never sit in the database as cleartext. We encrypt with AES-256-GCM, which needs a 32-byte key and a fresh 12-byte IV per encryption. The key lives in a secrets manager (Vault or AWS Secrets Manager), never in the database next to the data it protects.
const crypto = require('crypto');
const KEY = Buffer.from(process.env.SLACK_TOKEN_ENC_KEY, 'hex'); // 64 hex chars
function encrypt(plaintext) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, enc]); // iv(12) + tag(16) + ciphertext
}
function decrypt(buf) {
const iv = buf.subarray(0, 12);
const tag = buf.subarray(12, 28);
const enc = buf.subarray(28);
const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8');
}
The schema stores the encrypted bot token, an encrypted refresh token, an expiry timestamp, and the scope, plus a separate audit table that records every INSTALL, REFRESH, and REVOKE with who performed it. The bank retains that audit log for five years, so the audit write is not optional decoration; it is the thing the auditor actually reads.
CREATE TABLE slack_installations (
team_id VARCHAR(20) NOT NULL UNIQUE,
bot_user_id VARCHAR(20) NOT NULL,
bot_token_enc BYTEA NOT NULL,
bot_refresh_token_enc BYTEA,
bot_token_expires_at TIMESTAMPTZ,
scope TEXT NOT NULL,
installed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
rotated_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
);
Refresh tokens and rotation
Rotation is opt-in. You enable it at "OAuth & Permissions" under "Token Rotation", and once you do, the OAuth response changes shape: access_token becomes a short-lived xoxe.xoxb- token valid for twelve hours, and you also get a refresh_token and expires_in. Before the access token lapses you call oauth.v2.access again with grant_type=refresh_token to get a new pair.
async function refreshBotToken(teamId) {
const inst = await db.findInstallation({ teamId });
if (!inst.bot_refresh_token_enc) throw new Error('no refresh token');
const resp = await axios.post(
'https://slack.com/api/oauth.v2.access',
new URLSearchParams({
client_id: process.env.SLACK_CLIENT_ID,
client_secret: process.env.SLACK_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: decrypt(inst.bot_refresh_token_enc),
}).toString(),
{ headers: { 'content-type': 'application/x-www-form-urlencoded' } }
);
if (!resp.data.ok) throw new Error(`refresh failed: ${resp.data.error}`);
const expiresAt = new Date(Date.now() + resp.data.expires_in * 1000);
await db.query(
`UPDATE slack_installations
SET bot_token_enc = $2, bot_refresh_token_enc = $3,
bot_token_expires_at = $4, rotated_at = now()
WHERE team_id = $1`,
[teamId, encrypt(resp.data.access_token),
encrypt(resp.data.refresh_token), expiresAt]
);
await audit({ teamId, operation: 'REFRESH', performedBy: 'system' });
return resp.data.access_token;
}
Do not wait for tokens to expire and fail. Run a cron job every hour that scans for installations expiring within the next thirty minutes and refreshes them early. At call time, wrap the lookup in a get-or-refresh helper that refreshes anything inside a five-minute window, and cache the decrypted token in-process for a minute so you are not hitting the database on every request.
async function getBotToken(teamId) {
const inst = await db.findInstallation({ teamId });
if (!inst || inst.revoked_at) throw new Error('not installed');
const exp = inst.bot_token_expires_at?.getTime() || 0;
if (exp > 0 && exp < Date.now() + 5 * 60 * 1000) {
return refreshBotToken(teamId);
}
return decrypt(inst.bot_token_enc);
}
The mistakes that actually cost us
Reusing the code. A code is single-use. If the user refreshes the callback page the browser replays the same code and Slack returns code_already_used. Do not throw a 500 at an innocent user; if you already have a recent installation for that team, redirect to success silently.
Skipping the state check. Without state validation, an attacker can lure a user to a callback URL carrying a code for the attacker's workspace, and your app saves the attacker's token over the legitimate workspace's row. Every bot mention then quietly routes to the attacker. The 24-byte random state plus the httpOnly, secure, sameSite cookie is what stops this.
Leaking a token in logs. This is the one that hurt. During a security audit we found a full xoxe.xoxb- token sitting in CloudWatch because some code had logged a raw API response. We had to revoke it and reinstall every workspace, which cost a day of downtime. The fix is a logger that sanitizes before writing, masking anything matching /xox[bp]-[\w-]+/, plus a unit test on the masker so it never regresses.
function maskToken(token) {
if (!token || token.length <= 12) return '***';
return token.slice(0, 9) + '...' + token.slice(-4);
}
Losing the refresh token. Slack hands you a refresh token once per rotation cycle. If your code predates rotation and never reads the refresh_token field, you cannot refresh, the access token dies twelve hours later, and the bot goes dark with no recovery short of reinstalling every workspace. Test the full install, refresh, expire, refresh-again cycle on staging before you flip rotation on in production.
Finally, subscribe to the tokens_revoked event so that when a user uninstalls you mark the row revoked and stop trying to use a dead token. After revocation every API call returns token_revoked, which you catch and turn into a reinstall prompt.
Takeaway
Slack OAuth is not hard, but it is unforgiving: a missing state check is a CSRF hole, a logged token is an incident, and a dropped refresh token is a workspace-wide outage. Get the four-hop install flow right, store one encrypted token per team_id, audit every operation, and let a cron job rotate tokens before they expire, and the whole thing runs quietly for years. If you are building a multi-tenant Slack app and want it to pass a real security review the first time, get in touch or see how we help teams ship integrations.








