Logo

Building a Slack Bot for Akamai Cache Purge with AWS Lambda

9 min read
Node.jsSlackAWSAkamaiCaching

Table of Contents

Intro

Our team frequently received requests to purge Akamai CDN cache. Sometimes these requests came late at night when something urgent needed to go live. The manual process involved logging into the Akamai console, navigating to the right property, and executing the purge. It was tedious and error-prone.

The solution: a Slack bot that lets authorized team members purge cache directly from a private channel.

Why not AWS Chatbot or Bedrock? We already had a Lambda function for other Akamai operations, so adding a new endpoint was the path of least resistance.

Architecture Overview

The system involves four components: Slack, AWS Lambda, Akamai, and the Slack API for responses.

Sequence diagram showing the flow from Slack Bot to Lambda to Akamai

The flow works like this:

  1. User sends a message in the private Slack channel with cache purge details
  2. Slack sends an HTTP request to Lambda with signature headers
  3. Lambda verifies the signature, channel ID, and bot ID
  4. If valid, Lambda calls Akamai's purge API
  5. Lambda posts the result back to Slack as a thread reply

Security Considerations

Since cache purging can be expensive (millions of new requests if someone purges everything), security was critical:

  1. Slack Signature Verification - Verify requests actually come from Slack using HMAC-SHA256
  2. Channel Restriction - Only accept requests from a specific private channel
  3. Bot ID Validation - Ensure the request is from our specific bot
  4. Timestamp Validation - Reject requests older than 5 minutes to prevent replay attacks
  5. Input Validation - Validate cache type and path format

Slack Request Verification

Slack signs every request with a secret. The verification follows Slack's official docs:

1const crypto = require('crypto')
2
3export const isValidSignature = async (
4 event: APIGatewayProxyEvent,
5): Promise<boolean> => {
6 const headers = event.headers
7 const rawBody = event.body
8
9 const body = JSON.parse(rawBody)
10
11 // Restrict to specific channel and bot
12 const botId = body?.event?.bot_id
13 const channelId = body?.event?.channel
14
15 if (botId !== BOT_ID || channelId !== CHANNEL_ID) {
16 return false
17 }
18
19 const slackSignature =
20 headers['x-slack-signature'] || headers['X-Slack-Signature']
21 const slackTimestamp =
22 headers['x-slack-request-timestamp'] || headers['X-Slack-Request-Timestamp']
23
24 if (!slackSignature || !slackTimestamp) {
25 return false
26 }
27
28 const secrets = await getSecretsFromSecretManager()
29 const SLACK_SIGNING_SECRET = secrets[SECRET_KEYS.slackSigningSecret]
30
31 // Prevent replay attacks (5 min window)
32 const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 60 * 5
33 if (parseInt(slackTimestamp, 10) < fiveMinutesAgo) {
34 return false
35 }
36
37 // Create base string: v0:timestamp:body
38 const sigBaseString = `v0:${slackTimestamp}:${rawBody}`
39
40 // Compute HMAC-SHA256
41 const hmac = crypto.createHmac('sha256', SLACK_SIGNING_SECRET)
42 hmac.update(sigBaseString, 'utf8')
43 const mySignature = `v0=${hmac.digest('hex')}`
44
45 // Use timing-safe comparison to prevent timing attacks
46 const signatureIsValid = crypto.timingSafeEqual(
47 Buffer.from(mySignature, 'utf8'),
48 Buffer.from(slackSignature, 'utf8'),
49 )
50
51 return !!signatureIsValid
52}

The key steps:

  1. Check channel ID and bot ID first (fast fail)
  2. Validate timestamp is within 5 minutes
  3. Construct the signature base string: v0:{timestamp}:{body}
  4. Compute HMAC-SHA256 with the signing secret
  5. Use timingSafeEqual to prevent timing attacks

Lambda Handler

The main handler orchestrates everything:

1import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'
2const EdgeGrid = require('akamai-edgegrid')
3
4export const lambdaHandler = async (
5 event: APIGatewayProxyEvent,
6): Promise<APIGatewayProxyResult> => {
7 const payload = JSON.parse(event.body)
8
9 // Verify Slack signature
10 if (!(await isValidSignature(event))) {
11 return {
12 statusCode: 403,
13 body: JSON.stringify({ message: 'Forbidden' }),
14 }
15 }
16
17 // Handle Slack URL verification challenge
18 if (payload.type === 'url_verification') {
19 return {
20 statusCode: 200,
21 body: JSON.stringify({ challenge: payload.challenge }),
22 }
23 }
24
25 // Parse the message to extract cache type and target path
26 // The structure depends on your Slack Workflow form configuration
27 const { cacheType, targetPath } = parseSlackMessage(payload)
28 const isCacheTag = cacheType === 'tag'
29
30 // Validate input
31 if (!targetPath || cacheType === undefined) {
32 return {
33 statusCode: 200,
34 body: JSON.stringify({ message: 'Invalid Payload' }),
35 }
36 }
37
38 // Validate cache tag length (Akamai limit)
39 if (isCacheTag && targetPath.length > 128) {
40 sendMessageToSlack({
41 threadTs: payload.event.ts,
42 targetPath,
43 message: 'Invalid Cache Tag',
44 hasError: true,
45 })
46 return {
47 statusCode: 200,
48 body: JSON.stringify({ message: 'Invalid Cache Tag' }),
49 }
50 }
51
52 // Create .edgerc file for Akamai authentication
53 await createEdgercFile()
54
55 const response = await getSecretsFromSecretManager()
56 const AKAMAI_HOST = response[SECRET_KEYS.akamaiHost]
57 const PATH = `https://${AKAMAI_HOST}/ccu/v3/delete/${isCacheTag ? 'tag' : 'url'}/production`
58
59 const eg = new EdgeGrid({
60 path: EDGERC_FILE_PATH,
61 section: 'default',
62 })
63
64 return new Promise((resolve, reject) => {
65 eg.auth({
66 path: PATH,
67 method: 'POST',
68 headers: { 'content-type': 'application/json' },
69 body: JSON.stringify({ objects: [targetPath] }),
70 })
71
72 eg.send((error, response, body) => {
73 if (response) {
74 sendMessageToSlack({
75 threadTs: payload.event.ts,
76 targetPath,
77 hasError: false,
78 })
79 resolve({
80 statusCode: 200,
81 body: JSON.stringify({
82 message: `Successfully purged cache with ${targetPath}`,
83 }),
84 })
85 } else {
86 sendMessageToSlack({
87 threadTs: payload.event.ts,
88 targetPath,
89 message: 'Error sending request to EdgeGrid',
90 hasError: true,
91 })
92 reject(error || new Error('Unknown error occurred'))
93 }
94 })
95 })
96}

Akamai EdgeGrid Authentication

Akamai uses EdgeGrid for API authentication. The akamai-edgegrid npm package handles the signature generation, but it requires a .edgerc config file.

Since Lambda doesn't have persistent storage, we use Lambda's ephemeral storage (/tmp directory) to write the config file. The exact lifespan of ephemeral storage isn't guaranteed—it persists across warm invocations but gets cleared when the execution environment is recycled. To avoid unnecessary writes, we check if the file exists before creating it:

1import fs from 'fs/promises'
2
3const EDGERC_FILE_PATH = '/tmp/.edgerc'
4
5const fileExists = async (path: string): Promise<boolean> => {
6 try {
7 await fs.access(path)
8 return true
9 } catch {
10 return false
11 }
12}
13
14export const createEdgercFile = async (): Promise<void> => {
15 // Skip if file already exists (reuse across warm invocations)
16 if (await fileExists(EDGERC_FILE_PATH)) {
17 return
18 }
19
20 const secrets = await getSecretsFromSecretManager()
21
22 const edgercContent = `[default]
23client_secret = ${secrets.akamai_client_secret}
24host = ${secrets.akamai_host}
25access_token = ${secrets.akamai_access_token}
26client_token = ${secrets.akamai_client_token}`
27
28 await fs.writeFile(EDGERC_FILE_PATH, edgercContent, 'utf-8')
29}

The credentials are stored in AWS Secrets Manager. For Akamai, I created API credentials scoped only to cache purge operations for the specific property, following the principle of least privilege.

The Akamai purge API endpoint format is:

1POST /ccu/v3/delete/{type}/production

Where {type} is either url (for URL-based purge) or tag (for cache tag purge). The request body contains an array of objects to purge:

1{
2 "objects": ["https://example.com/path/to/purge"]
3}

Sending Results Back to Slack

After purging (or failing), the bot replies in the thread so users know what happened:

1const axios = require('axios')
2
3export const sendMessageToSlack = async ({
4 threadTs,
5 targetPath,
6 message,
7 hasError,
8}: {
9 threadTs: string
10 targetPath: string
11 message?: string
12 hasError: boolean
13}): Promise<void> => {
14 const secrets = await getSecretsFromSecretManager()
15 const SLACK_BOT_TOKEN = secrets[SECRET_KEYS.slackBotToken]
16
17 await axios.post(
18 'https://slack.com/api/chat.postMessage',
19 {
20 channel: CHANNEL_ID,
21 thread_ts: threadTs,
22 text: `${hasError ? 'Failed to Purge' : 'Successfully Purged'} ${targetPath}${hasError ? `\nCause: ${message}` : ''}`,
23 },
24 {
25 headers: {
26 Authorization: `Bearer ${SLACK_BOT_TOKEN}`,
27 'Content-Type': 'application/json',
28 },
29 },
30 )
31}

The thread_ts parameter ensures the response appears as a thread reply rather than a new message, keeping the channel clean.

AWS Infrastructure

The Lambda is defined in SAM template:

1AkamaiCachePurgeForSlackFunction:
2 Type: AWS::Serverless::Function
3 Properties:
4 FunctionName: my-akamai-cache-purge-slack-lambda
5 Handler: dist/handlers/akamaiCachePurgeForSlackHandler.lambdaHandler
6 Runtime: nodejs20.x
7 Architectures:
8 - x86_64
9 Events:
10 AkamaiCachePurgeApi:
11 Type: Api
12 Properties:
13 RestApiId: !Ref Api
14 Path: /akamai-cache-purge-for-slack
15 Method: post
16 Auth:
17 ApiKeyRequired: false
18 Role: !Sub arn:aws:iam::${AWS::AccountId}:role/my-lambda-execution-role

Note that ApiKeyRequired: false because authentication is handled by Slack signature verification instead.

Secrets Management

All sensitive values are stored in AWS Secrets Manager:

1import {
2 SecretsManagerClient,
3 GetSecretValueCommand,
4} from '@aws-sdk/client-secrets-manager'
5
6const SECRET_NAME = 'my-project/akamai-cache-purge'
7
8const client = new SecretsManagerClient({ region: 'ap-northeast-1' })
9
10export const getSecretsFromSecretManager = async () => {
11 const response = await client.send(
12 new GetSecretValueCommand({
13 SecretId: SECRET_NAME,
14 VersionStage: 'AWSCURRENT',
15 }),
16 )
17 return JSON.parse(response.SecretString)
18}

The secret contains:

Slack Workflow Setup

The Slack side uses a Workflow Builder form that captures:

  1. Executor - Who is requesting the purge (auto-filled)
  2. Cache Type - Dropdown to select between cache tag or URL purge
  3. Target Path - The URL or cache tag to purge

The workflow sends a formatted message to the channel, which triggers the bot. This structured input makes parsing reliable and prevents malformed requests.

Retrospective

What Worked Well

What I'd Do Differently

In retrospect, I could have used the Slack Bolt package to simplify the authentication flow. Bolt handles signature verification automatically:

1// With Bolt (what I could have done)
2import { App } from '@slack/bolt'
3
4const app = new App({
5 token: process.env.SLACK_BOT_TOKEN,
6 signingSecret: process.env.SLACK_SIGNING_SECRET,
7})
8
9app.message('purge', async ({ message, say }) => {
10 // Bolt handles all the signature verification
11 await purgeCache(message.text)
12 await say({ text: 'Cache purged!', thread_ts: message.ts })
13})

But I didn't know about Bolt at the time, and the manual implementation taught me a lot about how Slack authentication actually works.

Impact

This automation eliminated:

Team members can now purge cache in seconds from Slack, with full audit trail (Slack message history) and access control (private channel membership)

Resources

Related Articles