Canary Tokens for the AI Era: Detecting Rogue Agents in Your Cloud
AI agents can compromise a cloud account in minutes. Here is how a decades-old deception trick — the canary token — becomes your fastest, most reliable alarm against them.
The minutes that matter
Autonomous AI agents are changing the speed of cloud attacks. In controlled tests run by the security firm Tracebit, frontier AI agents reached a critical action in about 14 minutes on average, leaving defenders a window of roughly 8 minutes. Most security teams cannot even read an alert in that time, let alone investigate it.
Traditional detection struggles here. Anomaly detection needs a baseline and produces noise. Signature rules miss new techniques. Log reviews happen hours later. What defenders need is an alarm that is instant, cheap, and almost never wrong.
That alarm already exists. It is called a canary token: a fake secret that no legitimate person or system should ever touch. The moment anything reads it, you know something is wrong. And as you will see, AI agents are unusually good at stepping on these tripwires.
TL;DR
- What: a canary token is a decoy credential, file or resource. Any access to it is a high-confidence signal of intrusion.
- Why now: AI attack agents enumerate everything, move fast, and do not get suspicious. That makes them trip canaries far more reliably than many human attackers.
- Evidence: in published research, canaries alerted defenders in 95.9% of 162 successful AI-agent attack runs across 10 models.
- What you will build: realistic canary secrets in AWS Secrets Manager (TypeScript), an EventBridge rule that watches CloudTrail, a responder Lambda that alerts Slack and contains the identity, and a local simulator to test it all without an AWS account.
- Remember: canaries detect; they do not prevent. They belong on top of least privilege, not instead of it.
Who this is for
Beginners are welcome. Every concept is explained before the code uses it, and every code block is followed by a walkthrough of why it is written that way. If you already know canaries well, skip to the hands-on build.
Foundations: what is a canary token?
A canary token is a piece of bait that has no legitimate use. Because nobody should ever touch it, any touch is evidence of an intruder.
The name comes from coal mining. Miners carried canaries underground because the birds reacted to toxic gas before humans did. The bird was an early warning system. A canary token plays the same role in your infrastructure: it reacts first, so you can act before real damage is done.
A short history
The idea is older than the cloud. Security researchers in the early 2000s called these objects honeytokens, a cousin of the honeypot (a fake server built to attract attackers). The difference is size. A honeypot is a whole machine you must maintain. A honeytoken is a single fake item — a password, an API key, a database row, a document — that you sprinkle inside your real environment.
Over time the pattern spread everywhere:
- Fake database rows that alert when someone exports the customer table.
- Fake AWS access keys left in code repositories, which alert when an attacker tries to use them.
- Fake documents that phone home when opened.
- Fake secrets in a secrets manager, which alert when read. This is the type we build in this article.
Why canaries have almost no false positives
Most detection tools try to answer a hard question: is this behaviour unusual? That requires a baseline of “normal”, and normal changes every day. The result is alert fatigue — teams drown in warnings and start ignoring them.
A canary answers a much easier question: did anyone touch the thing nobody should touch? That is a yes/no fact, not a guess. If you design the canary well, the honest answer to “who should read this?” is “nobody”, so every alert deserves attention.
Think of it like a motion sensor in a locked room that no employee has a key to. You do not need machine learning to know that motion in that room is a problem.
The three properties of a good canary
- Believable. It must look like something an attacker wants: a production database password, an admin API key, a payment-provider token. A canary named
fake-secret-123catches nobody. - Unused. No real application, script or person reads it. This is what keeps false positives near zero.
- Observable. Every access is logged and routed to an alert within seconds. In AWS, CloudTrail records each
GetSecretValuecall, which makes Secrets Manager a natural home for canaries.
Key terms used in this article
Why AI agents trip canaries so reliably
AI attack agents are fast, but their habits make them easy to catch with decoys. Three traits matter most.
1. They enumerate everything
An agent works in a loop: the model looks at what it knows, picks a tool to call, reads the result, and repeats. When an agent lands in a cloud account with stolen credentials, the cheapest next step is almost always to list things — buckets, roles, functions and secrets — and then read the interesting ones.
An experienced human attacker might pause and ask, “Is prod/payments/stripe-live-key too convenient? Could it be a trap?" An agent chasing a goal like "find credentials that grant more access" rarely asks that question. A secret with a juicy name looks like progress, so it gets read.
2. They are fast, so they hit the trap early
Speed cuts both ways. The same speed that shrinks your response window also means the agent reaches your decoys within its first few minutes. A canary placed where enumeration naturally leads becomes one of the first things an agent touches — often long before it finds anything real.
3. They do not get tired or bored
Humans skip things. Agents are systematic. If ten secrets are listed, an agent may try to read all ten. Each extra read is another chance to hit a canary. Mixing several canaries among real resources raises the odds even more.
What the research shows
This is not just theory. Tracebit pointed frontier models at a realistic AWS environment and measured how often canaries fired. According to coverage in CSO Online, canaries alerted defenders in 95.9% of 162 successful attack runs performed by agents using 10 different AI models. In other words, when an agent succeeded at something, it almost always tripped a wire on the way.
That result is the core argument of this article: you do not need to out-think an AI attacker to detect it. You need to put bait where its loop will naturally look.
The timeline problem
Detection only matters if it happens before the damage. The diagram below compares a typical agent attack timeline with a manual response and an automated, canary-driven response. With a manual process, the alert may arrive in time but the human reaction does not. With automation, the canary read triggers containment in seconds — well inside the window.
The architecture: from bait to containment in seconds
The whole system is five AWS building blocks connected in a straight line, plus one optional branch for containment. Here is the flow before we write any code.
- The canary secret lives in AWS Secrets Manager next to your real secrets. It has a believable name and a believable (but fake) value. Nothing in your system reads it.
- CloudTrail records the read. Every call to
GetSecretValuebecomes an event that includes who called (the principal ARN), from where (source IP, user agent) and what they asked for (the secret ID). - EventBridge filters the stream. A rule matches only events where the requested secret is one of your canaries, so the downstream function runs only when it matters.
- The responder Lambda enriches and acts. It builds a readable alert, posts it to Slack (or SNS, PagerDuty, email), and — if you enable it — contains the identity.
- Containment attaches an explicit-deny policy to the IAM role or user that read the canary. In IAM, an explicit deny always beats an allow, so this instantly stops that identity from doing anything else.
Why this design?
Why Secrets Manager and not a fake access key? Reading a secret is exactly what an agent does when it looks for privilege escalation. The read itself is the signal; the attacker does not even have to use the fake value.
Why EventBridge instead of polling logs? Polling means delay and cost. EventBridge pushes matching events to Lambda as they arrive, typically within seconds to a few minutes of the API call, with no servers to run.
Why automate containment? Because of the timeline problem. If the agent’s whole attack fits inside 8 minutes, a human approval step may arrive too late. We will make containment a configurable switch so you can start in alert-only mode and turn it on once you trust it.
Why an explicit deny instead of deleting the role? Deleting things destroys evidence and can break production. A deny policy is reversible: remove it after the investigation and everything works again.
Project layout
All the code in the next sections fits into this small TypeScript project:
canary-agent-trap/
├── src/
│ ├── canaries.ts # Step 1: create canary secrets
│ ├── responder.ts # Step 3: Lambda that alerts + contains
│ └── simulate.ts # Step 4: local rogue-agent simulator
├── infra/
│ └── canary-stack.ts # Step 2: CDK stack (EventBridge rule + Lambda)
├── canaries.json # the list of canary names (your source of truth)
├── package.json
└── tsconfig.jsonInstall the dependencies once:
npm init -y
npm install @aws-sdk/client-secrets-manager @aws-sdk/client-iam
npm install -D typescript tsx @types/node @types/aws-lambda aws-cdk-lib constructsStep 1: Create realistic canary secrets
The first job is to plant bait that looks real. We keep the list of canaries in a JSON file so every other component — the detection rule, the responder, the simulator — reads from one source of truth.
canaries.json
{
"region": "us-east-1",
"canaries": [
{
"name": "prod/payments/stripe-live-secret",
"description": "Live Stripe secret key for checkout-service",
"kind": "api-key"
},
{
"name": "prod/rds/admin-credentials",
"description": "Master credentials for orders-db cluster",
"kind": "db-credentials"
},
{
"name": "ci/github/deploy-token",
"description": "Deploy token used by release pipeline",
"kind": "token"
}
]
}Theory — why these names? Attackers (human or AI) look for three things: production, privilege and money. Names containing prod, admin, live and payments signal all three. Copy the naming conventions your real secrets already use. If your real secrets look like orders-svc/prod/db, a canary called prod/rds/admin-credentials will stand out as fake to a careful reader.
src/canaries.ts
import { readFileSync } from "node:fs";
import { randomBytes } from "node:crypto";
import {
SecretsManagerClient,
CreateSecretCommand,
ResourceExistsException,
} from "@aws-sdk/client-secrets-manager";
type CanaryKind = "api-key" | "db-credentials" | "token";
interface CanaryDef {
name: string;
description: string;
kind: CanaryKind;
}
const config = JSON.parse(readFileSync("canaries.json", "utf8")) as {
region: string;
canaries: CanaryDef[];
};
const client = new SecretsManagerClient({ region: config.region });
// Random characters so every value looks unique and plausible.
function randomString(length: number): string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const bytes = randomBytes(length);
return Array.from(bytes, (b) => alphabet[b % alphabet.length]).join("");
}
// Build a fake value that matches the shape of a real one.
// None of these values work anywhere - they are pure decoys.
function fakeValue(kind: CanaryKind): string {
switch (kind) {
case "api-key":
return JSON.stringify({ apiKey: `sk_live_${randomString(32)}` });
case "db-credentials":
return JSON.stringify({
engine: "postgres",
host: "orders-db.cluster-internal.example",
port: 5432,
username: "db_admin",
password: randomString(24),
});
case "token":
return JSON.stringify({ token: `ghp_${randomString(36)}` });
}
}
async function createCanary(def: CanaryDef): Promise<void> {
try {
await client.send(
new CreateSecretCommand({
Name: def.name,
Description: def.description,
SecretString: fakeValue(def.kind),
// Tags look like a real team's tags. Never tag it "canary":
// anyone who can list secrets can also read tags.
Tags: [
{ Key: "team", Value: "platform" },
{ Key: "env", Value: "prod" },
],
}),
);
console.log(`created ${def.name}`);
} catch (err) {
if (err instanceof ResourceExistsException) {
console.log(`exists ${def.name} (skipped)`);
return;
}
throw err;
}
}
async function main(): Promise<void> {
for (const def of config.canaries) {
await createCanary(def);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});Run it with npx tsx src/canaries.ts.
Walkthrough: what each part does and why
Reading canaries.json. We load the canary list from a file instead of hard-coding it. Later, the CDK stack reads the same file to build the detection rule. One list means you can never add a canary and forget to watch it.
randomString uses crypto.randomBytes. Values must be unique per canary and unpredictable. If every canary shared the same value, an attacker who saw two of them would know they are fake. Math.random() would also work for decoys, but using the crypto module is a good habit for anything credential-shaped.
fakeValue mimics real formats. A Stripe live key starts with sk_live_; a GitHub personal token starts with ghp_; database secrets are JSON with host, port, username and password. Matching these shapes matters because an agent may parse the value and plan its next step from it. A realistic value keeps the decoy believable. The host uses the reserved .example domain so it can never resolve to a real machine.
Tags are camouflage. It is tempting to tag canaries with purpose=canary so your team can find them. Do not do this. ListSecrets returns tags, so the label would warn the attacker. Keep the canary list in canaries.json (in a private repo) instead.
Idempotency with ResourceExistsException. Running the script twice should not crash. Catching "already exists" makes the script safe to re-run in CI.
Why reading is enough. Notice we never need the attacker to use these values. The GetSecretValue call is logged by CloudTrail the moment it happens, which is what Step 2 watches for.
Step 2: Detect the read with EventBridge and CDK
Now we need to see every read of a canary. We use AWS CDK (infrastructure as code in TypeScript) to create an EventBridge rule and wire it to the responder Lambda from Step 3.
A gotcha beginners hit: read-only events
GetSecretValue is a read-only API call. By default, EventBridge rules ignore read-only management events from CloudTrail, because they are extremely high-volume. To receive them, the rule must use a special state, ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS, which today can only be set through the CLI or CloudFormation. AWS added this capability in late 2023.
If you skip this, your rule deploys fine, looks correct in the console — and never fires. Also confirm that CloudTrail is logging management events (read and write) in the account and region where your canaries live.
infra/canary-stack.ts
import { readFileSync } from "node:fs";
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as events from "aws-cdk-lib/aws-events";
import * as targets from "aws-cdk-lib/aws-events-targets";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as nodejs from "aws-cdk-lib/aws-lambda-nodejs";
import * as iam from "aws-cdk-lib/aws-iam";
const { canaries } = JSON.parse(readFileSync("canaries.json", "utf8")) as {
canaries: { name: string }[];
};
export class CanaryStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// 1. The responder Lambda (code in src/responder.ts)
const responder = new nodejs.NodejsFunction(this, "CanaryResponder", {
entry: "src/responder.ts",
runtime: lambda.Runtime.NODEJS_20_X,
timeout: cdk.Duration.seconds(30),
environment: {
SLACK_WEBHOOK_URL: process.env.SLACK_WEBHOOK_URL ?? "",
CONTAIN: process.env.CONTAIN ?? "false", // start in alert-only mode
ALLOWLIST_PRINCIPALS: process.env.ALLOWLIST_PRINCIPALS ?? "",
},
});
// The responder may attach a deny policy to roles and users - nothing more.
responder.addToRolePolicy(
new iam.PolicyStatement({
actions: ["iam:PutRolePolicy", "iam:PutUserPolicy"],
resources: ["*"],
}),
);
// 2. Match a canary whether it was requested by name or by full ARN.
const secretIdMatchers = canaries.flatMap((c) => [
c.name,
{ wildcard: `arn:aws:secretsmanager:*:*:secret:${c.name}-*` },
]);
const rule = new events.Rule(this, "CanaryReadRule", {
description: "Fires when any canary secret is read",
eventPattern: {
source: ["aws.secretsmanager"],
detailType: ["AWS API Call via CloudTrail"],
detail: {
eventSource: ["secretsmanager.amazonaws.com"],
eventName: ["GetSecretValue"],
requestParameters: { secretId: secretIdMatchers },
},
},
targets: [new targets.LambdaFunction(responder, { retryAttempts: 2 })],
});
// 3. Escape hatch: opt in to read-only management events.
(rule.node.defaultChild as events.CfnRule).state =
"ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTS";
}
}
const app = new cdk.App();
new CanaryStack(app, "CanaryAgentTrap");Deploy with npx cdk deploy --app "npx tsx infra/canary-stack.ts".
Walkthrough: how the rule thinks
An event pattern is a filter, not a query. EventBridge compares every incoming event to the pattern. Each key must match; inside an array, any value can match. So the pattern reads: “source is Secrets Manager AND it is a CloudTrail API call AND the action is GetSecretValue AND the secret ID is one of my canaries." Everything else is dropped for free, before your Lambda runs.
Get Vasanthan K’s stories in your inbox
Join Medium for free to get updates from this writer.
Why two matchers per canary? A caller can ask for a secret by its short name (prod/rds/admin-credentials) or by its full ARN. Secrets Manager adds a random six-character suffix to ARNs (for example ...:secret:prod/rds/admin-credentials-Ab12Cd). The wildcard matcher catches every ARN form, whatever the suffix.
Why read the same canaries.json? Single source of truth. Add a canary in Step 1, redeploy, and the rule picks it up automatically.
Why retryAttempts: 2? If the Lambda fails (Slack is down, a throttle), EventBridge retries. An alarm that silently fails once is worse than useless.
Why the escape hatch? CDK’s high-level Rule construct exposes only enabled or disabled. We reach into the underlying CloudFormation resource (CfnRule) and set the special state ourselves. This is a common CDK pattern whenever a feature is newer than the library's abstractions.
Least privilege for the defender too. The responder can only write inline policies to users and roles. It cannot delete anything, create users, or read your secrets. If the responder itself were ever compromised, the blast radius is small.
Step 3: The responder — alert, then contain
When the rule fires, EventBridge hands our Lambda the full CloudTrail record. The responder does four things in order: parse who did it, check the allowlist, alert humans, and optionally contain the identity.
src/responder.ts
import { IAMClient, PutRolePolicyCommand, PutUserPolicyCommand } from "@aws-sdk/client-iam";
import type { EventBridgeEvent } from "aws-lambda";
const iam = new IAMClient({});
// ---- Types: only the CloudTrail fields we use ----
export interface CloudTrailDetail {
eventTime: string;
eventName: string;
sourceIPAddress?: string;
userAgent?: string;
requestParameters?: { secretId?: string };
userIdentity: {
type: string; // "IAMUser" | "AssumedRole" | "Root" | ...
arn?: string;
userName?: string;
sessionContext?: { sessionIssuer?: { type?: string; userName?: string; arn?: string } };
};
}
export interface Finding {
secretId: string;
principalArn: string;
identityType: string;
roleName?: string;
userName?: string;
sourceIp: string;
userAgent: string;
time: string;
looksLikeAiAgent: boolean;
}
// ---- 1. Parse ----
export function toFinding(d: CloudTrailDetail): Finding {
const issuer = d.userIdentity.sessionContext?.sessionIssuer;
const ua = d.userAgent ?? "unknown";
return {
secretId: d.requestParameters?.secretId ?? "unknown",
principalArn: issuer?.arn ?? d.userIdentity.arn ?? "unknown",
identityType: d.userIdentity.type,
roleName: issuer?.type === "Role" ? issuer.userName : undefined,
userName: d.userIdentity.type === "IAMUser" ? d.userIdentity.userName : undefined,
sourceIp: d.sourceIPAddress ?? "unknown",
userAgent: ua,
time: d.eventTime,
// A weak hint only: scripted SDK/CLI calls, not the console.
looksLikeAiAgent: /boto3|aws-cli|aws-sdk|python-requests|langchain|mcp/i.test(ua),
};
}
// ---- 2. Allowlist ----
export function isAllowlisted(f: Finding, allowlist: string[]): boolean {
return allowlist.some((a) => a && f.principalArn.startsWith(a));
}
// ---- 3. Alert ----
export function formatAlert(f: Finding, contained: string): string {
return [
`:rotating_light: *Canary secret read* - likely intrusion`,
`*Secret:* ${f.secretId}`,
`*Principal:* ${f.principalArn} (${f.identityType})`,
`*Source IP:* ${f.sourceIp}`,
`*User agent:* ${f.userAgent}${f.looksLikeAiAgent ? " _(scripted / possible agent)_" : ""}`,
`*Time:* ${f.time}`,
`*Containment:* ${contained}`,
].join("\n");
}
async function sendSlack(text: string): Promise<void> {
const url = process.env.SLACK_WEBHOOK_URL;
if (!url) {
console.warn("SLACK_WEBHOOK_URL not set; alert only logged");
return;
}
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
if (!res.ok) throw new Error(`Slack returned ${res.status}`);
}
// ---- 4. Contain ----
export function denyPolicy(now: Date, revokeSessionsOnly: boolean): string {
const statement: Record<string, unknown> = {
Sid: "CanaryContainment",
Effect: "Deny",
Action: "*",
Resource: "*",
};
if (revokeSessionsOnly) {
// Deny only credentials issued before "now" - the same idea as
// the console's "Revoke active sessions" button.
statement.Condition = { DateLessThan: { "aws:TokenIssueTime": now.toISOString() } };
}
return JSON.stringify({ Version: "2012-10-17", Statement: [statement] });
}
async function contain(f: Finding): Promise<string> {
const now = new Date();
if (f.roleName) {
await iam.send(new PutRolePolicyCommand({
RoleName: f.roleName,
PolicyName: "canary-containment",
PolicyDocument: denyPolicy(now, true),
}));
return `revoked active sessions of role ${f.roleName}`;
}
if (f.userName) {
await iam.send(new PutUserPolicyCommand({
UserName: f.userName,
PolicyName: "canary-containment",
PolicyDocument: denyPolicy(now, false),
}));
return `denied all actions for user ${f.userName}`;
}
return `not supported for identity type ${f.identityType} - respond manually`;
}
// ---- Handler ----
export async function handler(
event: EventBridgeEvent<"AWS API Call via CloudTrail", CloudTrailDetail>,
): Promise<void> {
const finding = toFinding(event.detail);
const allowlist = (process.env.ALLOWLIST_PRINCIPALS ?? "").split(",");
// Structured log line: easy to search in CloudWatch Logs Insights.
console.log(JSON.stringify({ msg: "canary_read", ...finding }));
if (isAllowlisted(finding, allowlist)) {
console.log(JSON.stringify({ msg: "allowlisted_principal", principal: finding.principalArn }));
return;
}
let contained = "disabled (alert-only mode)";
if (process.env.CONTAIN === "true") {
try {
contained = await contain(finding);
} catch (err) {
contained = `FAILED: ${(err as Error).message}`;
}
}
await sendSlack(formatAlert(finding, contained));
}Walkthrough: the decisions behind the code
Pure functions first, side effects last. toFinding, isAllowlisted, formatAlert and denyPolicy take data and return data. They never call AWS or Slack. That separation is what lets Step 4 test the whole logic on your laptop. Only contain, sendSlack and handler touch the outside world.
Finding the real identity. When an agent uses a role, CloudTrail’s userIdentity.arn is a session ARN such as arn:aws:sts::123456789012:assumed-role/ci-runner/session-xyz. You cannot attach a policy to a session. The sessionIssuer block tells us the underlying role (ci-runner), which is what we contain. For IAM users, the user name is right in userIdentity.
Two containment strategies. For an IAM user (long-lived access keys, a classic leak), we deny everything: those keys should never work again. For a role, a blanket deny would also break every legitimate service using that role — possibly your production app. Instead we use the condition aws:TokenIssueTime with DateLessThan: it denies only credentials issued before the incident. The attacker's stolen session dies; fresh sessions for your real workloads keep working. AWS uses the same technique behind its "Revoke active sessions" console button.
Why Action: "*" with Effect: "Deny"? IAM evaluates explicit denies before any allow. No matter what permissions the attacker had, this single statement overrides them all.
Alert even when containment fails. Notice the try/catch around contain. If containment fails (for example, the role is protected by a permissions boundary), we still send the alert with FAILED in it. A human must know.
The “looks like an AI agent” hint is deliberately weak. User-agent strings are easy to fake. We use it only to add context to the alert, never to decide anything. The canary read itself is the real signal — whoever made it, human or agent, should not have.
Alert-only by default. CONTAIN=false is the default. Run in alert-only mode for a couple of weeks, review every alert, tune the allowlist, then turn containment on. Automation you do not trust gets switched off; automation you have tested gets left on.
Step 4: Test it locally with a simulated rogue agent
You should never wait for a real attacker to find out whether your alarm works. This step builds a tiny simulator that runs on your laptop, with no AWS account and no LLM. It mixes real-looking secrets with your canaries, lets a scripted “agent” choose what to read, and runs the responder’s pure functions on the result.
src/simulate.ts
import { readFileSync } from "node:fs";
import { toFinding, formatAlert, denyPolicy, type CloudTrailDetail } from "./responder";
// ---- 1. A fake account: real secrets mixed with canaries ----
const { canaries } = JSON.parse(readFileSync("canaries.json", "utf8")) as {
canaries: { name: string }[];
};
const canaryNames = new Set(canaries.map((c) => c.name));
const realSecrets = [
"orders-svc/prod/redis-url",
"orders-svc/prod/feature-flags",
"reporting/staging/smtp-password",
];
const allSecrets = [...realSecrets, ...canaryNames].sort();
// ---- 2. A scripted "rogue agent" ----
// Not an LLM: a stand-in that behaves the way agents typically do -
// list everything, rank by how valuable a name looks, read the top hits.
const JUICY = /prod|admin|live|payments|deploy|root|master/i;
function agentPicks(secretNames: string[], maxReads: number): string[] {
return secretNames
.map((name) => ({ name, score: (name.match(new RegExp(JUICY, "gi")) ?? []).length }))
.filter((s) => s.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, maxReads)
.map((s) => s.name);
}
// ---- 3. Turn each read into a CloudTrail-shaped record ----
function cloudTrailRecord(secretId: string, at: Date): CloudTrailDetail {
return {
eventTime: at.toISOString(),
eventName: "GetSecretValue",
sourceIPAddress: "203.0.113.42", // documentation-only IP range
userAgent: "Boto3/1.35.0 md/agent-framework",
requestParameters: { secretId },
userIdentity: {
type: "AssumedRole",
arn: "arn:aws:sts::111122223333:assumed-role/ci-runner/stolen-session",
sessionContext: {
sessionIssuer: {
type: "Role",
userName: "ci-runner",
arn: "arn:aws:iam::111122223333:role/ci-runner",
},
},
},
};
}
// ---- 4. Run the simulation ----
const start = new Date("2026-10-17T14:00:00Z");
const reads = agentPicks(allSecrets, 4);
console.log(`Agent listed ${allSecrets.length} secrets and chose to read ${reads.length}:\n`);
let firstAlertAt: Date | undefined;
reads.forEach((secretId, i) => {
const at = new Date(start.getTime() + (i + 1) * 20_000); // one read every 20 s
const isCanary = canaryNames.has(secretId);
console.log(`${at.toISOString().slice(11, 19)} read ${secretId.padEnd(36)} ${isCanary ? "<-- CANARY" : ""}`);
if (isCanary && !firstAlertAt) {
firstAlertAt = at;
const finding = toFinding(cloudTrailRecord(secretId, at));
console.log("\n--- alert that would be sent ---");
console.log(formatAlert(finding, `would revoke active sessions of role ${finding.roleName}`));
console.log("\n--- containment policy that would be attached ---");
console.log(JSON.stringify(JSON.parse(denyPolicy(at, true)), null, 2));
console.log("");
}
});
if (firstAlertAt) {
const seconds = (firstAlertAt.getTime() - start.getTime()) / 1000;
console.log(`Detected ${seconds}s after the agent started reading secrets.`);
} else {
console.log("No canary was read. Rethink your canary names and placement!");
}Run it with npx tsx src/simulate.ts. This is the actual output:
Agent listed 6 secrets and chose to read 4:
14:00:20 read prod/payments/stripe-live-secret <-- CANARY
--- alert that would be sent ---
:rotating_light: *Canary secret read* - likely intrusion
*Secret:* prod/payments/stripe-live-secret
*Principal:* arn:aws:iam::111122223333:role/ci-runner (AssumedRole)
*Source IP:* 203.0.113.42
*User agent:* Boto3/1.35.0 md/agent-framework _(scripted / possible agent)_
*Time:* 2026-10-17T14:00:20.000Z
*Containment:* would revoke active sessions of role ci-runner
--- containment policy that would be attached ---
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "CanaryContainment",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"DateLessThan": {
"aws:TokenIssueTime": "2026-10-17T14:00:20.000Z"
}
}
}
]
}
14:00:40 read prod/rds/admin-credentials <-- CANARY
14:01:00 read ci/github/deploy-token <-- CANARY
14:01:20 read orders-svc/prod/feature-flags
Detected 20s after the agent started reading secrets.Walkthrough: what the simulation teaches
The scoring function is the whole lesson. agentPicks counts how many "valuable" words appear in each secret name and reads the highest scorers first. Real agents are far more sophisticated, but the instinct is the same: chase what looks like production, privilege and money. Our canaries scored highest, so they were read before the real secrets. That is exactly the outcome you design for.
Read the output as a test result. If your simulator ever prints “No canary was read”, your canaries are not attractive enough compared with your real secrets. Rename them until they win. This turns canary design from guesswork into something you can measure.
Reserved values keep the demo safe. The IP 203.0.113.42 comes from a range reserved for documentation, and account 111122223333 is the placeholder AWS uses in its docs. Nothing in this simulation can reach a real system.
Same code as production. The simulator imports toFinding, formatAlert and denyPolicy from the real responder. If the alert looks right here, it will look right in Slack.
Unit tests for the responder
The pure functions also make classic unit tests easy. Node’s built-in test runner is enough — no Jest required.
// src/responder.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { toFinding, isAllowlisted, denyPolicy, type CloudTrailDetail } from "./responder";
const roleEvent: CloudTrailDetail = {
eventTime: "2026-10-17T14:00:20Z",
eventName: "GetSecretValue",
userAgent: "Boto3/1.35.0",
requestParameters: { secretId: "prod/rds/admin-credentials" },
userIdentity: {
type: "AssumedRole",
arn: "arn:aws:sts::111122223333:assumed-role/ci-runner/session-1",
sessionContext: {
sessionIssuer: { type: "Role", userName: "ci-runner", arn: "arn:aws:iam::111122223333:role/ci-runner" },
},
},
};
test("resolves an assumed-role session to its underlying role", () => {
const f = toFinding(roleEvent);
assert.equal(f.roleName, "ci-runner");
assert.equal(f.principalArn, "arn:aws:iam::111122223333:role/ci-runner");
});
test("resolves an IAM user", () => {
const f = toFinding({
...roleEvent,
userIdentity: { type: "IAMUser", userName: "legacy-bot", arn: "arn:aws:iam::111122223333:user/legacy-bot" },
});
assert.equal(f.userName, "legacy-bot");
assert.equal(f.roleName, undefined);
});
test("allowlist matches by ARN prefix and ignores empty entries", () => {
const f = toFinding(roleEvent);
assert.equal(isAllowlisted(f, ["arn:aws:iam::111122223333:role/ci-"]), true);
assert.equal(isAllowlisted(f, [""]), false);
});
test("role containment only denies tokens issued before the incident", () => {
const now = new Date("2026-10-17T14:00:20Z");
const policy = JSON.parse(denyPolicy(now, true));
assert.equal(policy.Statement[0].Effect, "Deny");
assert.equal(policy.Statement[0].Condition.DateLessThan["aws:TokenIssueTime"], now.toISOString());
});Run with npx tsx --test src/responder.test.ts; all four tests pass.
The empty-string allowlist test guards a subtle bug. If ALLOWLIST_PRINCIPALS is unset, splitting "" on commas yields [""], and every ARN starts with an empty string. Without the a && check in isAllowlisted, an unconfigured allowlist would silently allowlist everyone and your alarm would never ring.
Placement strategy and avoiding false positives
A canary only works if an attacker walks past it and your own systems never do. Placement is about both halves of that sentence.
Where to put canaries
Think about the path an agent takes after it gets a foothold, and put bait at each step:
- Secrets Manager and Parameter Store. The first place anyone looks for credentials. Plant two or three canaries per account and region.
- CI/CD systems. A fake deploy token in your pipeline’s variable store catches attackers who compromised a build runner.
- Code repositories. A fake access key in an old config file catches attackers scanning source for leaked credentials. (Use a key that is not attached to any real permissions.)
- Developer workstations and shared drives. A
credentials.bakfile or.env.productioncopy catches agents that start on a laptop and pivot to the cloud. - Every account, not just production. Attackers often enter through a forgotten sandbox or staging account. A canary there is the cheapest early warning you will ever buy.
Mix canaries with real resources. If all your canaries sit in one obvious corner, a careful attacker can learn to avoid that corner. Spread out, they make every read a gamble.
Where false positives come from (and how to stop them)
Canaries are near-zero noise in theory. In practice, a few legitimate systems read secrets in bulk. Plan for them before you turn on containment:
Protect your own agents from your own traps
This is the modern twist. Many teams now run internal AI agents with cloud access. Those agents enumerate just like attackers do — so they will trip your canaries too.
The fix is not to allowlist your agents (an attacker who hijacks your agent would then be invisible). The fix is least privilege: scope your agent’s IAM policy to the specific secrets it actually needs.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "OnlyTheSecretsThisAgentNeeds",
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:us-east-1:111122223333:secret:orders-svc/prod/redis-url-*"
}
]
}Now your agent physically cannot read a canary. If an alert ever fires with your agent’s role as the principal, the call was denied by IAM but still logged — which means something tried to push your agent outside its scope. That alert is worth investigating too.
Note on denied calls: CloudTrail logs GetSecretValue attempts even when IAM denies them (with an errorCode of AccessDenied). Our rule fires on attempts as well as successes. That is a feature: an attacker who tries to read a canary is just as suspicious as one who succeeds.
Limitations: what canaries cannot do
Canaries are one of the best signal-to-noise tools in security, but be honest about their limits before you rely on them.
- They detect; they do not prevent. Even with automated containment, an attacker may have done some damage before touching a canary. The agent in our simulation read a canary first only because the canary was more attractive than the real secrets.
- A careful attacker can avoid them. A human or an agent instructed to read only secrets referenced by running workloads might skip unused secrets. Canaries raise the cost of attacking; they do not make it impossible.
- Delivery is fast, not instant. CloudTrail-to-EventBridge delivery usually takes seconds to a few minutes. That is far better than a daily log review, but it is not zero.
- Containment has edges. Root user activity cannot be blocked by an IAM policy. Some identities (federated users, cross-account sessions) need different handling. The responder reports these as “respond manually” so a human picks them up.
- Canaries need care. When your naming conventions change, update the canaries. A canary that looks out of date is a canary that gets ignored.
- Research results are not guarantees. The detection rates quoted earlier come from a vendor’s controlled cyber range. Your environment, attackers and agents will differ. Measure your own coverage with tools like the simulator from Step 4.
Where canaries fit: defense in depth
No single control stops a determined attacker. Security teams stack layers so that each one catches what the others miss. Canaries sit in the detect and respond layer, on top of preventive controls.
- Prevent — least privilege. Every identity, human or AI, gets only the permissions it needs. This shrinks what an attacker can reach in the first place.
- Prevent — short-lived credentials. Prefer IAM roles and temporary sessions over long-lived access keys. Stolen credentials expire on their own.
- Limit — isolation. Separate accounts for production, staging and sandboxes, plus network boundaries, so one compromise does not become every compromise.
- Detect — canaries. High-confidence tripwires that catch anyone exploring where they should not be.
- Detect — broad monitoring. GuardDuty, anomaly detection and log analytics catch attacks that never touch a canary.
- Respond — automated containment plus a human runbook. The Lambda buys time; your incident process finishes the job: rotate credentials, review what else the identity touched, and remove the deny policy only when the investigation is complete.
The most important takeaway: a canary alert is the start of an incident, not the end. Containment stops the bleeding. Investigation tells you how the attacker got in, so you can close that door for good.
Conclusion
AI agents have made cloud attacks faster, but they have not made them smarter in every way. Their strength — relentless, systematic enumeration — is also their weakness. They read the things that look valuable, and a well-designed canary is built to look valuable.
With about 350 lines of TypeScript, you now have:
- believable canary secrets that share one source of truth,
- an EventBridge rule that catches every read, by name or ARN, including the read-only events most tutorials miss,
- a responder that alerts within seconds and can revoke an attacker’s session without breaking your real workloads,
- and a simulator plus unit tests, so you can prove it works before an attacker does.
Start in alert-only mode this week. Plant three canaries in your most important account, run the simulator, and watch your first alert arrive. Then expand to every account, tune the allowlist, and switch containment on.
Key takeaways
- A canary token is bait with no legitimate use, so any touch is a high-confidence alarm.
- AI attack agents enumerate and read juicy-looking secrets, which makes them trip canaries reliably.
- Read-only CloudTrail events need the
ENABLED_WITH_ALL_CLOUDTRAIL_MANAGEMENT_EVENTSrule state, or your rule never fires. - Contain roles by revoking old sessions with
aws:TokenIssueTime, not with a blanket deny. - Scope your own AI agents with least privilege so they never hit your traps.
- Canaries are a detection layer; least privilege and incident response still do the heavy lifting.
Sources
- Tracebit — Context bombs: Canaries that stop AI attackers (attack timing in AWS cyber range)
- CSO Online — Context bombing heralds a new AI era of deceptive defense (canary detection rate across 162 runs)
- AWS — Receiving read-only management events from AWS services
- AWS — EventBridge announces support for read-only API events from CloudTrail








