House Notes

Plant a decoy AWS key, not a flag.txt, and confirm its alert email before you trust it

When a post on X described a flag.txt that tells an intruding AI agent to close the hole it used and disconnect, we planted a decoy AWS key instead. Its alarm fired 2 minutes 17 seconds after one call with the key, but its only subscriber, an email address, had not confirmed, and no email arrived. Once it confirmed, a drill’s alarm email arrived within 2 seconds of firing. Here is the setup, with a step that checks confirmation.

Verified on AWS CLI 1.46.1, re-run 2026-10-24

Touches: creates a CloudTrail trail with its S3 bucket, a CloudWatch Logs group and the role that writes to it, one IAM user with one access key, a metric filter, an alarm, and an SNS topic with an email subscription.
Undo: we have not run this undo. Remove the planted key and any local copy, then delete the alarm, the metric filter, the SNS topic and its subscription, the access key, the user's deny-all policy and then the IAM user; keep the trail, role, log group and bucket as your audit log, or delete the trail, the role's policy and the role, the log group, and the bucket once it is empty.
Say to the agent: Plant a decoy AWS access key in my account under a policy that denies every action, raise an alarm when CloudTrail logs a call made with it, check that the alert email reads confirmed before the key is used or planted, and test it once with the key and after that only by drill.

Run details

Status: published
Tool: AWS CLI
Tool version: 1.46.1
Runtime: Python 3.12.3
OS: Linux (WSL2)
Environment note: log group, alarm and SNS topic in us-east-1 under one all-Region CloudTrail trail; the test call ran against eu-west-1
Re-run: 2026-10-24
Owner: Will Mitchell

A flag.txt asks the intruder to leave

@Nexuist wrote on 2026-10-23:

Putting a flag.txt in my root directory containing a prompt injection that congratulates the agent for passing the eval and then instructs it to patch whatever entry method it used and disconnect from the server

X counted 818,626 views of that post by October 26. The file is a request: it works only if whoever finds it does what it asks. By our reading, a person or a script reads past it, and so does an agent told to ignore instructions it finds in files. We answered it the next day:

fun idea, but this won’t do anything. any model smart enough to break in isn’t going to follow your instructions.

but this did inspire me to build a lightweight tripwire system to actually achieve what flag.txt is trying to do. basically plant AWS keys with zero permissions all over the place and get alerts if they ever get used.

easy to set up. ask your clanker.

A decoy key asks nothing of whoever finds it. It does nothing until someone calls AWS with it. Most AWS API calls write an event to CloudTrail, AWS’s record of API calls, and the alarm counts those events. Put a separate key in each place you want to watch, each with its own alarm whose description names that place.

Grafana’s decoy keys caught a real attacker

Grafana Labs planted decoy AWS keys in its organization- and repository-level secrets. On April 26, a vulnerable GitHub Action workflow gave an attacker access to a limited number of tokens. In its write-up, dated August 2025, Grafana says the attacker took the secrets and ran TruffleHog, an open-source secret scanner, to check them. TruffleHog’s sts:GetCallerIdentity call against the decoy keys set off an alert, because Thinkst was watching those keys’ AWS accounts. Grafana wrote:

An AWS API key was validated by the attacker; we got a real-time alert; teams swarmed; and the intrusion was contained within minutes.

Where the keys sat told Grafana where the attacker got in: “That one notification, thanks to our strategic token placement, revealed the exact entry point.”

TruffleHog says it spots the free decoy keys without using them

Thinkst’s free service at canarytokens.org also hands out decoy AWS keys. TruffleHog’s makers say it spots those keys without using them. It decodes the AWS account ID from a key’s ID and checks it against the accounts canarytokens.org uses. Its makers say the check “does make the AWS canaries less effective, if the attacker is sophisticated enough to search for them with TruffleHog.”

TruffleHog’s makers also say they are “not making an effort to include account ID’s associated with Thinkst’s paid offerings”. Grafana wrote that it moved from Thinkst’s open-source and free editions to Thinkst’s cloud version. By our reading of TruffleHog’s post, a key made in your own AWS account is not on its list either.

GetCallerIdentity answers even under a policy that denies everything. AWS says that if a policy “explicitly denies access to the sts:GetCallerIdentity action, you can still perform this operation.” Our deny-all key answered with its ARN, the name AWS gives each identity, and the alarm counted the call.

Building it yourself has costs. Thinkst added a fallback for the few AWS calls that never reach CloudTrail, and this setup has none. Your key also tells whoever holds it your AWS account ID. Both are under the limits at the end.

AWS logged success while our only subscriber was unconfirmed

On 2026-10-24 (UTC) our setup script subscribed one email address to an SNS topic, the AWS service that passes an alert on to its subscribers. The script’s readback printed that subscription as PendingConfirmation. Next we made one call with the decoy key, aws sts get-caller-identity, against AWS’s eu-west-1 Region. Less than a minute after the call, we planted the key in the place it watches.

Our CloudWatch alarm moved from OK to ALARM at 02:18 UTC. In the same second, the alarm’s history read Successfully executed action for our topic. The email subscription read PendingConfirmation before the call and still read it at 04:03 UTC. AWS says “You must confirm the subscription before the email address can start to receive messages.” Our inbox, spam folder and trash hold no email from that alarm. By those records, the alarm reached no one, and the line that reads like success printed anyway.

For about half an hour after we planted the key, that unconfirmed address was its alarm’s only subscriber. Then we subscribed a queue to the topic as a second channel. A small computer at home reads that queue, posts what arrives to our Discord, and sends each alarm to a phone as a critical alert. The queue was not yet subscribed when the alarm fired at 02:18. That relay is our own, and this card does not cover it.

AWS deletes an email subscription that was never confirmed after 48 hours. If that address was the alarm’s only subscriber, the alarm then has no one to tell. Step 4 reads the state back instead of trusting the click, before you use or plant the key.

Plant a decoy key in your own account

You can hand the run card’s sentence to your coding agent and check its work against each Check below. These steps need an AWS identity that can create IAM users and roles, a CloudTrail trail, an S3 bucket, a CloudWatch Logs group, an SNS topic and a CloudWatch alarm. That identity can write IAM policies, so treat it as an admin credential and keep it out of every place you plant a decoy. We made the same AWS calls from a script with AWS CLI 1.46.1. The commands below write those calls out, and a step says where ours differed. We did not try version 2 of the CLI. Keep the log group, the alarm and the topic in one Region. We used us-east-1.

  1. Send CloudTrail to CloudWatch Logs. CloudTrail records API calls as events. The alarm in step 3 counts events in a CloudWatch Logs group, so the trail has to deliver to one. If you already have a trail that records read and write management events in every Region and delivers them to a log group, use it, run the Check below, and go to step 2.

    Otherwise, ask your agent to create three things: an S3 bucket with a bucket policy that lets CloudTrail write to it, a log group, and an IAM role that CloudTrail can assume with logs:CreateLogStream and logs:PutLogEvents on that log group. Then create the trail:

    aws cloudtrail create-trail --name <trail-name> \
      --s3-bucket-name <trail-bucket> \
      --is-multi-region-trail --include-global-service-events --enable-log-file-validation \
      --cloud-watch-logs-log-group-arn "arn:aws:logs:us-east-1:<account-id>:log-group:<log-group>:*" \
      --cloud-watch-logs-role-arn <role-arn>
    aws cloudtrail start-logging --name <trail-name>
    aws cloudtrail get-event-selectors --trail-name <trail-name>
    aws cloudtrail get-trail-status --name <trail-name>

    This trail sends every management event from every enabled Region to the log group. Our test call ran against eu-west-1, and our alarm in us-east-1 still caught it. AWS says a multi-Region trail sends log files from all enabled Regions to the one log group you name. By our reading, GetCallerIdentity is a read event, so a trail that records only write events would miss it.

    Our first create-trail, run just after our script created the role, failed with InvalidCloudWatchLogsRoleArnException. The script retried it after a 10-second wait, and that worked.

    AWS says “The first copy of management events is delivered free of charge in each Region” and charges for additional copies, so reuse a trail you already have. S3 charges apply to the bucket, and the link to CloudWatch Logs adds “standard CloudWatch Logs and CloudWatch charges”. We have not read our bill.

    Check: in get-event-selectors, ReadWriteType reads All and IncludeManagementEvents reads true. get-trail-status shows "IsLogging": true and no LatestCloudWatchLogsDeliveryError.

  2. Make a user whose policy denies every action, and give it one key. Give the user a plausible name, such as one an old job might have used.

    aws iam create-user --user-name <decoy-user>
    aws iam put-user-policy --user-name <decoy-user> --policy-name deny-all \
      --policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"*","Resource":"*"}]}'
    touch decoy-key.json && chmod 600 decoy-key.json
    aws iam create-access-key --user-name <decoy-user> > decoy-key.json

    decoy-key.json now holds the key, readable only by your user. Our script wrote the file with mode 600 from Python instead of the touch and chmod lines. Keep it out of any repository.

    Check: aws iam list-user-policies --user-name <decoy-user> lists only deny-all. aws iam list-attached-user-policies --user-name <decoy-user> and aws iam list-groups-for-user --user-name <decoy-user> return empty lists.

  3. Count each use of the key, and alarm on the first. The metric filter adds 1 to a metric for each matching event. The alarm fires when the one-minute sum reaches 1.

    aws sns create-topic --name decoy-key-alerts
    aws sns subscribe --topic-arn <topic-arn> --protocol email \
      --notification-endpoint <your-email> --return-subscription-arn
    aws logs put-metric-filter --log-group-name <log-group> --filter-name decoy-key-used \
      --filter-pattern '{ ($.userIdentity.accessKeyId = "<decoy-key-id>") || ($.userIdentity.userName = "<decoy-user>") }' \
      --metric-transformations '[{"metricName":"DecoyKeyUsed","metricNamespace":"DecoyKeys","metricValue":"1"}]'
    aws cloudwatch put-metric-alarm --alarm-name decoy-key-used \
      --alarm-description "Decoy key used. It lives only in <place>. Treat that place as leaked." \
      --namespace DecoyKeys --metric-name DecoyKeyUsed \
      --statistic Sum --period 60 --evaluation-periods 1 --threshold 1 \
      --comparison-operator GreaterThanOrEqualToThreshold \
      --treat-missing-data notBreaching --alarm-actions <topic-arn>

    <decoy-key-id> is the AccessKeyId in decoy-key.json. Our first subscribe ran without --return-subscription-arn. With it, our second subscribe printed the ARN that step 4 reads. Write where the key lives into the alarm description. Our relay’s Discord post carried that text, so the drill alert named the place to treat as leaked.

    AWS’s example of a CloudTrail alarm keeps the console’s default period of 5 minutes, with a threshold of 1. We set one minute. With notBreaching, the alarm reads OK while no event arrives.

    Check: run the pattern against two sample events before any real use. This is the command we ran, with made-up key IDs and user names:

    aws logs test-metric-filter \
      --filter-pattern '{ ($.userIdentity.accessKeyId = "AKIAIOSFODNN7EXAMPLE") || ($.userIdentity.userName = "old-ci-deployer") }' \
      --log-event-messages \
        '{"userIdentity":{"type":"IAMUser","userName":"old-ci-deployer","accessKeyId":"AKIAIOSFODNN7EXAMPLE"}}' \
        '{"userIdentity":{"type":"IAMUser","userName":"deploy-bot","accessKeyId":"AKIAI44QH8DHBEXAMPLE"}}'

    Put your decoy’s key ID and user name in the pattern and the first event, and one of your real users in the second. The matches list holds event 1 only.

  4. Confirm the email, then read its state back. AWS sends a confirmation link to the address. Click it, then read the subscription’s state with the ARN that subscribe printed:

    aws sns get-subscription-attributes --subscription-arn <subscription-arn> \
      --query Attributes.PendingConfirmation --output text

    Our subscription list still showed PendingConfirmation more than a minute after a click that came no later than 04:03 UTC. At 04:05 UTC we ran the same aws sns subscribe again, which sent a fresh link, and our next read, 10 seconds later, showed it confirmed. That fresh link’s email still sits unread in our inbox, and our last read before the re-send came 22 seconds earlier, so we can’t say what confirmed it. If yours still prints true a few minutes after your click, run subscribe again for a fresh link, click it, and read the state again. We have not seen that step fix a pending address. Then publish one test message to the topic:

    aws sns publish --topic-arn <topic-arn> --subject "decoy key test" \
      --message "Test message. No key was used."

    Ours went out with a different subject and body. We published ours at 04:05:37 UTC, and the Received header Google added to it puts its arrival at 04:05:38 UTC.

    Check: get-subscription-attributes prints false, and the test message reaches your inbox.

  5. Use the key once yourself, before you plant it. This is the only test that runs a call with the key through the trail, the filter, the alarm and the topic. Note the time you run it. The first command needs jq to read the key from decoy-key.json.

    AWS_ACCESS_KEY_ID=$(jq -r .AccessKey.AccessKeyId decoy-key.json) \
    AWS_SECRET_ACCESS_KEY=$(jq -r .AccessKey.SecretAccessKey decoy-key.json) \
      env -u AWS_PROFILE aws sts get-caller-identity --region eu-west-1 --query Arn --output text
    aws cloudwatch describe-alarm-history --alarm-name decoy-key-used \
      --query 'AlarmHistoryItems[].[Timestamp,HistorySummary]' --output text

    Run the history command 5 minutes after the call, and again a few minutes later if it shows nothing yet. Ours fired after 2 minutes 17 seconds. We made our call before we planted the key, so that call was ours.

    Check: the call prints arn:aws:iam::<your-account-id>:user/<decoy-user>. The history shows Alarm updated from OK to ALARM and Successfully executed action. Those two lines alone don’t show that anyone received the alert.

  6. Plant the key, and test from then on with drills. Put it in the one place you want this key to watch: a credentials file, a secrets store, or a server’s environment. Use a name your own tools do not read by default. In that place, don’t use the [default] profile or the AWS_ACCESS_KEY_ID variable. AWS says “The default profile is automatically picked up by any AWS application in the environment”. AWS also says that each SDK’s chain of credential sources “most often” includes variables such as AWS_ACCESS_KEY_ID. By our reading, your own code would then use the decoy and set off the alarm on every run, and so would a coding agent that reads that file on its own while it works.

    After planting, delete decoy-key.json. A leftover copy is a second place the key can leak from, and the alarm description names only the first.

    For each further place, repeat step 2 with a new user and key. Then run step 3’s put-metric-filter, put-metric-alarm and Check with new names, pointed at the same topic, so every alarm publishes to the email you confirmed in step 4. Each alarm’s description names its own place. We made step 5’s call with one key only.

    After planting, test with set-alarm-state. It changes the alarm’s state by hand and never touches the key. AWS says “When the updated state differs from the previous value, the action configured for the appropriate state is invoked.” For an alarm whose action is your topic, changing its state to ALARM this way “sends an SNS message”. Run it while the alarm reads OK.

    aws cloudwatch set-alarm-state --alarm-name decoy-key-used \
      --state-value ALARM --state-reason "DRILL: no key was used"

    A drill sends a real alert to every confirmed subscriber. Put “DRILL” in the reason so nobody treats the alert as a leak. Our relay titled the alert DRILL because the reason started with it. If an alert rings someone’s phone, send a drill only when they expect it.

    When an alarm fires outside a drill, first check whether the call came from your own address and your own tools. CloudTrail’s event for the call records both, as sourceIPAddress and userAgent. If it did, fix the caller and replace the decoy key, because the key now sits in that caller’s logs. If it did not, treat the key’s place as leaked. Rotate every real secret stored there, starting with the most powerful.

    Check: the drill’s alert reaches each subscriber you confirmed, the email included. Our drill on 2026-10-24 ran before the email confirmed and left no email. On 2026-10-26, after it confirmed, a drill’s email reached our inbox within 2 seconds of the alarm firing.

PASS: PendingConfirmation reads false, one call with the decoy key moves the alarm to ALARM, and step 6’s drill puts the alarm’s email in your inbox.

Most of the wait came before our search could find the event

We timed one use of the key, from the time CloudTrail logged for the call:

  • After 53 seconds, a search of the log group for the key ID found nothing.
  • After 1 minute 55 seconds, the same search still found nothing. The alarm read OK, with the reason no datapoints were received for 1 period.
  • After 2 minutes 17 seconds, the alarm moved to ALARM. SNS took the message in the same second.
  • A later search found the event, recorded in eu-west-1.

The alarm fired 22 seconds after we started the last search that came up empty. AWS says “Typically, CloudTrail delivers an event within 5 minutes of the API call.” Ours is one sample. Plan for five minutes or more.

On 2026-10-26, a drill’s email reached our inbox within 2 seconds of the alarm firing, by the Received header Google added to it. Our relay’s Discord post came about 30 seconds after SNS took a drill’s message on 2026-10-24, and about 68 seconds after the 2026-10-26 drill’s.

What you will see

  • An error occurred (InvalidCloudWatchLogsRoleArnException) when calling the CreateTrail operation: Access denied. Verify in IAM that the role has adequate trust relationships. Step 1 prints it from create-trail. The message points at the role’s trust policy. Ours came just after our script created the role, and the same call worked on a retry 10 seconds later.
  • "matches" with one entry, "eventNumber": 1. Step 3 prints it from test-metric-filter. A second entry means the pattern also matches your real user.
  • PendingConfirmation. aws sns list-subscriptions-by-topic prints it in place of the subscription ARN until the address confirms. The email address receives nothing yet. Step 4’s command prints true until then, AWS says. Our reads of that command came after the email confirmed and printed only false.
  • arn:aws:iam::<your-account-id>:user/<decoy-user>. Step 5 prints it from get-caller-identity. If it prints your own user’s ARN, the decoy key did not reach the CLI, and the alarm will not fire.
  • Successfully executed action arn:aws:sns:.... Step 5’s history command prints it when the alarm hands its message to SNS. A drill prints it too. It does not mean anyone received the alert.

What this doesn’t show

  • No call with the key has reached a confirmed email. Our one call with the key came while the email was unconfirmed. The alarm email we saw arrive came from a drill, which starts at the alarm and skips the key, the trail and the filter.
  • Tested only by us, with one call. Every alarm in this card came from our own test call or a drill. Grafana’s case shows a decoy key catching a real attacker, but with keys Thinkst watched, not this setup. The timing from our one call is one sample, from a call against eu-west-1 to an alarm in us-east-1. We did not test the 5-minute alarm period or a single-Region trail, and we did not time the alert’s arrival on the phone. Our only call was GetCallerIdentity, which the deny-all policy cannot block, so we have not seen a denied call reach the alarm. We have no count of false alarms yet. Grafana wrote that “a handful of teammates accidentally tripped tokens simply by poking around”, and your own team may do the same.
  • Calls that skip CloudTrail go unseen. Thinkst says “the AWS API has a small number of endpoints which don’t log their own usage to CloudTrail”. This alarm counts only CloudTrail events, so it misses those calls. Thinkst’s fallback reads the IAM credential report, which can be generated only once every four hours. We did not add it.
  • The key tells its holder your account ID. GetCallerIdentity returns an ARN that contains your AWS account ID. TruffleHog can also decode the account ID from the key’s ID alone. Anyone holding the decoy learns the ID of the account you built this in.
  • No bill read and no teardown. We did not measure what the trail, the log group and the alarm cost. We have not run the undo in the run card.

Reader poll

Self-selected, unverified responses. These counts describe this poll's responses.

JavaScript is needed to send an answer and load this poll's counts.

House of Vibe

House Notes come from House of Vibe, the StartupBros membership for building with AI. Inside: guided projects, members who help when you're stuck, and a live workshop every month where your questions get answered.

Enter the House