SaaS Slack Integration: OAuth, Notifications & Commands

The fastest way to make a B2B product sticky is to stop making people open a tab to use it. A SaaS Slack integration moves your alerts, approvals, and reports into the place your users already stare at all day — and a passive dashboard becomes an active part of their workflow. The catch is that Slack endpoints are public-facing, multi-tenant, and time-constrained, so doing this carelessly hands an attacker a forged-payload button straight into your database.
Here's the whole architecture in one breath: run real OAuth per workspace, encrypt the tokens, HMAC-verify every incoming request, and acknowledge within three seconds while doing the actual work in a background queue. Get those four right and the rest is Block Kit layout. Let's build it in NestJS.

Multi-Tenant OAuth: One Workspace, One Row
To let many companies connect their own Slack workspaces, you need an OAuth 2.0 flow, not a hardcoded webhook. When a tenant admin clicks "Add to Slack," generate a signed state token, redirect to Slack's authorization server, and on callback exchange the temporary code for that workspace's permanent bot_access_token and team_id. Persist them per tenant:
1CREATE TABLE slack_workspace_installations (
2 id BIGSERIAL PRIMARY KEY,
3 tenant_id UUID NOT NULL UNIQUE REFERENCES tenants(id) ON DELETE CASCADE,
4 slack_team_id VARCHAR(64) NOT NULL UNIQUE,
5 slack_team_name VARCHAR(255) NOT NULL,
6 bot_access_token TEXT NOT NULL, -- encrypt with AES-256 before storing
7 incoming_channel_id VARCHAR(64),
8 installed_by_user_id UUID NOT NULL,
9 created_at TIMESTAMPTZ DEFAULT NOW()
10);
11
12CREATE INDEX idx_slack_tenant ON slack_workspace_installations (tenant_id, slack_team_id);The OAuth dance is the same shape as any social-login OAuth2 flow — the difference is you're storing a long-lived bot token that, if leaked, opens someone else's workspace. Encrypt it at rest. The average breach runs $4.88M (IBM, 2024); plaintext third-party tokens in a backup are exactly the kind of thing that ends up in one.

Block Kit: Send Actions, Not Plain Text
Plain-text messages drown in a busy channel. Use Block Kit to send structured, actionable messages — an alert the user can act on without leaving Slack:
1// src/integrations/slack/templates/notification.template.ts
2export function buildApprovalBlock(projectName: string, requestId: string) {
3 return {
4 blocks: [
5 {
6 type: 'section',
7 text: { type: 'mrkdwn', text: `*Deployment alert:* request pending for *${projectName}*` },
8 },
9 {
10 type: 'actions',
11 elements: [
12 { type: 'button', text: { type: 'plain_text', text: 'Approve' }, style: 'primary',
13 action_id: 'approve_build', value: requestId },
14 { type: 'button', text: { type: 'plain_text', text: 'Reject' }, style: 'danger',
15 action_id: 'reject_build', value: requestId },
16 ],
17 },
18 ],
19 };
20}HMAC Verification: Trust Nothing
This is the one people skip and regret. A public endpoint that triggers internal actions, with no signature check, is a forged-payload waiting to happen. Slack signs every request — recompute the HMAC and reject anything that doesn't match or is stale:
1// src/integrations/slack/guards/slack-signature.guard.ts
2import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
3import * as crypto from 'crypto';
4
5@Injectable()
6export class SlackSignatureGuard implements CanActivate {
7 private readonly MAX_SKEW_SECONDS = 300;
8
9 canActivate(context: ExecutionContext): boolean {
10 const req = context.switchToHttp().getRequest();
11 const signature = req.headers['x-slack-signature'];
12 const timestamp = req.headers['x-slack-request-timestamp'];
13 const rawBody = req.rawBody; // requires preserving the raw body in NestJS
14
15 if (!signature || !timestamp || !rawBody) {
16 throw new UnauthorizedException('Missing Slack signature headers.');
17 }
18
19 // Block replays: reject stale timestamps.
20 const now = Math.floor(Date.now() / 1000);
21 if (Math.abs(now - parseInt(timestamp, 10)) > this.MAX_SKEW_SECONDS) {
22 throw new UnauthorizedException('Slack request timestamp expired.');
23 }
24
25 const base = `v0:${timestamp}:${rawBody}`;
26 const expected =
27 'v0=' +
28 crypto.createHmac('sha256', process.env.SLACK_SIGNING_SECRET!).update(base, 'utf8').digest('hex');
29
30 // Timing-safe compare so an attacker can't guess the signature byte by byte.
31 const valid =
32 expected.length === signature.length &&
33 crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
34
35 if (!valid) throw new UnauthorizedException('Invalid Slack signature.');
36 return true;
37 }
38}The timing-safe compare matters: a normal string comparison bails on the first mismatched character, and those microsecond differences are enough for an attacker to guess a signature one byte at a time.
Slash Commands: Ack in 3 Seconds, Work in a Queue

Slack demands a response within 3,000ms. Run a multi-row query or an external call inline and your users get "operation timed out" in chat. So acknowledge instantly and offload the real work to a queue:
1// src/integrations/slack/slack-command.controller.ts
2import { Controller, Post, Body, UseGuards, HttpCode, HttpStatus } from '@nestjs/common';
3import { InjectQueue } from '@nestjs/bullmq';
4import { Queue } from 'bullmq';
5import { SlackSignatureGuard } from './guards/slack-signature.guard';
6
7@Controller('integrations/slack')
8export class SlackCommandController {
9 constructor(@InjectQueue('slack-interaction-queue') private readonly queue: Queue) {}
10
11 @Post('commands')
12 @UseGuards(SlackSignatureGuard)
13 @HttpCode(HttpStatus.OK)
14 async slashCommand(@Body() payload: any) {
15 await this.queue.add('run-command', {
16 command: payload.text,
17 teamId: payload.team_id,
18 userId: payload.user_id,
19 responseUrl: payload.response_url, // worker POSTs the real result here later
20 });
21
22 return { response_type: 'ephemeral', text: 'Working on it…' };
23 }
24}The worker does the heavy lifting and POSTs the result back to response_url (valid ~30 minutes) whenever it's done. This is the same "ack fast, process async" pattern from our job-queue comparison, and it's why a queue isn't optional here — it's the thing standing between you and a 3-second timeout.
Test It Locally First
You don't need a deploy to see an interaction fire. Pair the Slack CLI with ngrok or zrok to tunnel Slack's webhooks straight to localhost, then inspect raw payloads, step through the signature guard, and tweak Block Kit layouts with a real loop. Wire the resulting events into your broader notification system so Slack is one channel among email and in-app, not a bolted-on island.
SaaS Slack Integration: The Security Checklist
| Layer | Vulnerable default | Production practice |
|---|---|---|
| Tokens | One hardcoded webhook | Per-workspace OAuth, AES-256 at rest |
| Authenticity | Open public endpoint | HMAC-SHA256, timing-safe compare |
| Replays | Ignore timestamp | Reject requests older than 5 min |
| Latency | Work inline in the handler | Ack in 3s, process via BullMQ |
Build it in this order and a Slack integration stops being a security liability and becomes the feature that makes your product feel like it lives where your customers work. Verify the signature, encrypt the token, answer in three seconds — and then let the queue quietly do the actual work while the user gets on with their day.
Frequently Asked Questions
Never hardcode a single webhook URL or bot token. Run a full OAuth 2.0 flow per workspace: generate a signed state token, redirect to Slack, exchange the returned code for that workspace's bot_access_token and team_id, and store them in a per-tenant table. Encrypt the token at rest. Each customer's Slack workspace then maps to one row, scoped by tenant_id, so messages and commands route to the right place.
Every Slack request includes an X-Slack-Signature and X-Slack-Request-Timestamp header. Recompute the HMAC-SHA256 of v0:{timestamp}:{rawBody} with your signing secret, compare it to the signature using a timing-safe comparison, and reject anything where the timestamp is more than five minutes old to block replays. Without this check, anyone can POST a forged payload to your endpoint.
Slack requires an acknowledgement within 3,000ms or the user sees a timeout error in chat. If your handler runs database joins or external API calls inline, you'll blow that window under load. The fix: verify the signature, push the work onto a background queue, return an immediate 200 with an 'ephemeral' acknowledgement, then send the real result later via the request's response_url (valid for ~30 minutes).
Yes. A bot token grants access to a customer's workspace, so a leaked database backup or SQL injection that exposes plaintext tokens is a multi-tenant breach. Encrypt tokens at rest with AES-256 and decrypt only when sending. The average breach costs $4.88M (IBM, 2024) — token-at-rest encryption is cheap insurance against being the cause of one.
Use the Slack CLI together with a tunnel like ngrok or zrok to forward Slack's webhooks to your local NestJS app on localhost. That gives you a fast loop to inspect raw payloads, step through signature verification, and tweak Block Kit layouts before anything ships. You don't need to deploy to a public server just to see an interaction fire.
