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.
-
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:CreateLogStreamandlogs:PutLogEventson 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,
GetCallerIdentityis 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 withInvalidCloudWatchLogsRoleArnException. 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,ReadWriteTypereadsAllandIncludeManagementEventsreadstrue.get-trail-statusshows"IsLogging": trueand noLatestCloudWatchLogsDeliveryError. -
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.jsondecoy-key.jsonnow holds the key, readable only by your user. Our script wrote the file with mode 600 from Python instead of thetouchandchmodlines. Keep it out of any repository.Check:
aws iam list-user-policies --user-name <decoy-user>lists onlydeny-all.aws iam list-attached-user-policies --user-name <decoy-user>andaws iam list-groups-for-user --user-name <decoy-user>return empty lists. -
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 theAccessKeyIdindecoy-key.json. Our firstsubscriberan without--return-subscription-arn. With it, our secondsubscribeprinted 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
matcheslist holds event 1 only. -
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
subscribeprinted:aws sns get-subscription-attributes --subscription-arn <subscription-arn> \ --query Attributes.PendingConfirmation --output textOur subscription list still showed
PendingConfirmationmore than a minute after a click that came no later than 04:03 UTC. At 04:05 UTC we ran the sameaws sns subscribeagain, 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 printstruea few minutes after your click, runsubscribeagain 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-attributesprintsfalse, and the test message reaches your inbox. -
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
jqto read the key fromdecoy-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 textRun 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 showsAlarm updated from OK to ALARMandSuccessfully executed action. Those two lines alone don’t show that anyone received the alert. -
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 theAWS_ACCESS_KEY_IDvariable. 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 asAWS_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-alarmand 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 toALARMthis 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
sourceIPAddressanduserAgent. 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 fromcreate-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 fromtest-metric-filter. A second entry means the pattern also matches your real user.PendingConfirmation.aws sns list-subscriptions-by-topicprints it in place of the subscription ARN until the address confirms. The email address receives nothing yet. Step 4’s command printstrueuntil then, AWS says. Our reads of that command came after the email confirmed and printed onlyfalse.arn:aws:iam::<your-account-id>:user/<decoy-user>. Step 5 prints it fromget-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.
GetCallerIdentityreturns 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.