Human Approval Workflow for AI Agents: Slack, Jira and Pull Requests

Approvals work only if people see them and can act on them easily. A human approval workflow brings the decision to where your team already works, without weakening the rules. In this lesson you will add Slack buttons with verified signatures, and learn when a pull request is the better place to approve.

📖 What You’ll Learn

  • Which approval channel suits each decision
  • How to verify that a request really came from Slack
  • How to prevent replayed requests
  • Why generated code should be reviewed in a pull request

💡 Choose the Right Human Approval Workflow

Explain Like I’m 5: Different Signatures

Imagine: A parent can sign a school trip form quickly. Buying a house needs a lawyer to read every page.

What it really means: Match the depth of review to the risk.

In testing: A quick button may suit a test run, but code needs a proper review.

ChannelBest forLimit
API or web pageAny decision, with full detailPeople must remember to look
SlackQuick, low-risk approvalsLittle room to review
JiraDecisions that need a ticket trailSlower
Pull requestReviewing generated codeNeeds a code reviewer

A pull request gives you a diff, comments and branch protection, so it suits generated tests best. You will open one in Lesson 89.


🔍 Trust Only Signed Requests

Explain Like I’m 5: A Wax Seal

Imagine: A king’s letter carries a wax seal. If the seal is broken or wrong, nobody trusts the letter.

What it really means: A signature proves that the request came from Slack.

In testing: Anyone can send a request, but only Slack can sign it correctly.

Your webhook must be reachable by Slack, so anyone on the internet could also try to call it. Without verification, a stranger could send a fake “approve” click.

Slack signs each request using your signing secret. You rebuild the signature from the raw body and compare the two. Create src/integrations/slack.ts.

import { createHmac, timingSafeEqual } from 'node:crypto';
import express, { type Express } from 'express';
import { recordDecision, type ApprovalNotifier } from '../service/stored-approver.js';
import type { ServiceStore } from '../service/store.js';

export function verifySlackSignature(input: {
  secret: string;
  timestamp: string;
  signature: string;
  rawBody: string;
  nowSeconds: number;
}): boolean {
  const age = Math.abs(input.nowSeconds - Number(input.timestamp));
  if (!Number.isFinite(age) || age > 300) {
    return false;
  }
  const expected = `v0=${createHmac('sha256', input.secret)
    .update(`v0:${input.timestamp}:${input.rawBody}`)
    .digest('hex')}`;
  const a = Buffer.from(expected);
  const b = Buffer.from(input.signature);
  return a.length === b.length && timingSafeEqual(a, b);
}

export type SlackAction = { slackUser: string; approvalId: string; approved: boolean };

export function parseSlackAction(rawBody: string): SlackAction | null {
  try {
    const payload = JSON.parse(new URLSearchParams(rawBody).get('payload') ?? '');
    const [decision, approvalId] = String(payload.actions[0].value).split(':');
    if (!approvalId || !['approve', 'reject'].includes(decision)) {
      return null;
    }
    return { slackUser: String(payload.user.id), approvalId, approved: decision === 'approve' };
  } catch {
    return null;
  }
}

export function mountSlackRoutes(
  app: Express,
  store: ServiceStore,
  secret: string,
  approvers: Record<string, string>,
): void {
  app.post(
    '/webhooks/slack',
    express.raw({ type: 'application/x-www-form-urlencoded', limit: '64kb' }),
    (request, response) => {
      const rawBody = Buffer.isBuffer(request.body) ? request.body.toString('utf8') : '';
      const trusted = verifySlackSignature({
        secret,
        rawBody,
        timestamp: request.header('x-slack-request-timestamp') ?? '',
        signature: request.header('x-slack-signature') ?? '',
        nowSeconds: Math.floor(Date.now() / 1000),
      });
      if (!trusted) {
        response.status(401).send('Invalid signature');
        return;
      }
      const action = parseSlackAction(rawBody);
      const actorId = action ? approvers[action.slackUser] : undefined;
      if (!action || !actorId) {
        response.json({ text: 'You are not registered as an approver.' });
        return;
      }
      const result = recordDecision(store, action.approvalId, actorId, action.approved);
      response.json({ text: result === 'ok' ? 'Decision recorded.' : `Not recorded: ${result}` });
    },
  );
}

export function createSlackNotifier(webhookUrl: string): ApprovalNotifier {
  return async (approval) => {
    const button = (text: string, decision: string, style: string) => ({
      type: 'button',
      text: { type: 'plain_text', text },
      style,
      value: `${decision}:${approval.id}`,
    });
    const response = await fetch(webhookUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        text: `Approval needed: ${approval.action}`,
        blocks: [
          {
            type: 'section',
            text: {
              type: 'mrkdwn',
              text: `*${approval.action}* for run \`${approval.run_id}\`. Review the details in the service first.`,
            },
          },
          {
            type: 'actions',
            elements: [
              button('Approve', 'approve', 'primary'),
              button('Reject', 'reject', 'danger'),
            ],
          },
        ],
      }),
    });
    if (!response.ok) {
      throw new Error(`Slack returned ${response.status}`);
    }
  };
}
CheckWhat it stops
HMAC signatureForged requests
Five-minute windowOld requests being replayed
timingSafeEqualGuessing a signature by timing
Raw body, before parsingSignature mismatches
Slack user mappingUnknown people approving
recordDecisionSelf-approval and double decisions

Keep the message short and safe

The Slack message links to the run and does not include the generated code or any page text. Application content could contain secrets or hostile links.


🧩 Test the Signature Check

Explain Like I’m 5: The Fake Seal

Imagine: You try sealing a letter with a potato stamp, just to check that the guards notice.

What it really means: Test with changed and expired requests.

In testing: A changed body and an old timestamp must both fail.

Create tests/unit/slack.test.ts.

import assert from 'node:assert/strict';
import { createHmac } from 'node:crypto';
import { test } from 'node:test';
import { parseSlackAction, verifySlackSignature } from '../../src/integrations/slack.js';

const secret = 'test-signing-secret';
const rawBody = `payload=${encodeURIComponent(
  JSON.stringify({ user: { id: 'U1' }, actions: [{ value: 'approve:apr-1' }] }),
)}`;
const nowSeconds = 1_800_000_000;
const sign = (timestamp: number, body: string) =>
  `v0=${createHmac('sha256', secret).update(`v0:${timestamp}:${body}`).digest('hex')}`;
const check = (timestamp: number, signature: string) =>
  verifySlackSignature({ secret, timestamp: String(timestamp), signature, rawBody, nowSeconds });

test('accepts a correctly signed request', () => {
  assert.equal(check(nowSeconds, sign(nowSeconds, rawBody)), true);
});

test('rejects a changed body', () => {
  assert.equal(check(nowSeconds, sign(nowSeconds, `${rawBody}x`)), false);
});

test('rejects an old request, even with a valid signature', () => {
  const old = nowSeconds - 301;
  assert.equal(check(old, sign(old, rawBody)), false);
});

test('reads the decision from the button', () => {
  assert.deepEqual(parseSlackAction(rawBody), { slackUser: 'U1', approvalId: 'apr-1', approved: true });
  assert.equal(parseSlackAction('payload=not-json'), null);
});

🛠️ Connect It

In your Slack app settings, turn on interactivity and set the request URL to your service’s /webhooks/slack address. Then add these settings, using your own values.

SLACK_SIGNING_SECRET=your-signing-secret
SLACK_WEBHOOK_URL=your-incoming-webhook-url
SLACK_APPROVERS={"U012ABC":"ben"}

SLACK_APPROVERS maps a Slack user to a person in your service. Anyone who is not listed is refused, even if they can see the channel. Expose only this single path through your proxy. In src/service/main.ts, pass the notifier and mount the route, as shown in the final file in Lesson 88.


📌 Handling Slow Decisions

Our approval waits up to 60 minutes and then counts as a no. Choose a time that suits your team. A rejected or expired run stops safely, and it can be requested again.

The worker waits while an approval is pending, which pauses the queue. That is acceptable for one team. If it becomes a bottleneck, save the run’s state, free the worker and resume after the decision.


🌍 Real-Life Example: The Rubber Stamp

A team gets dozens of approval messages a day, and people start clicking without reading. A generated test that does nothing gets approved. Moving code review to pull requests, and keeping Slack for small decisions, brings real attention back. You saw this risk in MCP Security for Testers.


🧪 Try It Yourself

  1. Create the files and run the unit tests.
  2. Change one character of a signature in a test.
  3. Add a test for a missing timestamp.
  4. Map a test user in SLACK_APPROVERS.
  5. Decide which channel suits each of your approvals.

📋 Ready-to-Copy Prompt

You are a security engineer reviewing a webhook that approves AI agent
actions.

Code:
[paste the webhook with no secrets]

Check signature verification, replay protection, raw body handling, user
mapping and what happens with malformed input.

Confirm the current signing rules against the vendor's official docs, and
write a negative test for each problem that you find.

⚠️ Common Mistakes

  • Trusting a webhook without checking its signature
  • Parsing the body before verifying it
  • Letting anyone in a channel approve
  • Sending code or page text to a chat channel
  • Asking for so many approvals that nobody reads them

🎯 Key Takeaways

  • Match the approval channel to the risk.
  • Verify every webhook using its signature.
  • Only mapped people can approve.
  • Generated code deserves a real review.

🎤 Interview Questions

1. How do you verify a Slack webhook?

Model answer: I recompute an HMAC of the version, timestamp and raw body using the signing secret. I compare it in constant time, and reject old timestamps.

2. What is a replay attack?

Model answer: An attacker resends a valid captured request. A timestamp window limits how long a captured request can work.

3. Why is the raw body needed?

Model answer: The signature covers the exact bytes that were sent. Parsing and re-serialising can change them, so the signature would not match.

4. Where should generated code be approved?

Model answer: In a pull request, because it provides a diff, comments and branch protection. A chat button does not support a proper code review.

5. How do you prevent approval fatigue?

Model answer: I ask only for decisions that matter, make each request specific and move detailed reviews to a suitable tool.


📝 Assignment

Write a table of every action your agent can take, its risk and the approval channel it needs. Then add a test which proves that an unmapped Slack user cannot approve anything.


❓ FAQ

Can I use Microsoft Teams?

Yes. The idea is the same, but its signing and message formats differ, so follow its documentation.

Does Slack need to reach my private network?

Yes, for buttons to work. Expose only the webhook path through your proxy, or use approvals through the API instead.


🚀 Next Steps

Well done on finishing this lesson. Complete the assignment, then continue with Prompt Versioning and Evaluation Gates: CI/CD for AI Agents.