Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 12 min read

Your AI keys were leaked at a platform. A local guard you can paste catches the abuse before the invoice.

If an AI API key you store on a platform was just leaked, rotation is the headline. It is not the whole fix. Rotation stops a key you own from being stolen again. It does not stop a key that is already out of your hands

If an AI API key you store on a platform was just leaked, rotation is the headline. It is not the whole fix. Rotation stops a key you own from being stolen again. It does not stop a key that is already out of your hands from being spent. The window between "the platform was breached" and "you notice the charges" is where the damage happens, and a dashboard email will not beat that window. The platform's own detection will not either, because the platform is the side that was breached.

The fix that can beat the window is on your side of the wire: a check that runs in your code, before the call leaves, that does not ask "is this key valid" but "is this key being used the way this key is supposed to be used." The key is valid. That is exactly the problem. A stolen key is, from the API's point of view, a perfectly valid key. The only thing that is wrong is the shape of its use, and the only place you can see that shape before the bill is your own call path.

What the two October incidents actually show

Two independent disclosures in the last week share the same shape, and they both point at customer AI keys specifically.

Northflank, a cloud app-hosting platform, disclosed on October 30, 2026 that a breach led to unauthorized use of customers' AI API keys. The company's own timeline, as reported by BleepingComputer, is the part that matters: a cloud security firm sent an alert at 11:43 UTC, an engineer reviewed it one minute later, patches went out at 12:50 and 13:05 UTC. That is a fast response. It is still the platform describing its own response, to the platform's own customers, after the keys had already moved. Northflank then told every customer to assume all stored environment variables had been decrypted and to rotate every key, and it confirmed that multiple customers had reported unauthorized use of AI API keys sitting on the platform. Customers were told to review their provider usage and billing logs for activity they did not start.

Chainalysis, the blockchain-intelligence firm, disclosed on Friday of this week that it detected an external actor using what it calls "offensive agentic techniques" to reach its internal systems and tools. It says some customer information was accessed: names, email addresses, user IDs, organizational role labels. It says it found no evidence that investigation data or authentication credentials were exposed. Note the structure of that sentence. "We have no evidence that X leaked" is the most common phrasing in a disclosure, and it is not the same as "we checked and X did not leak." You do not get the difference from reading the release. You get it from the fact that the company is still determining which records were touched per organization.

Read the two together and the pattern is consistent: the keys are the thing that converts a breach into a bill, the platform responds faster than the theft window closes but not faster than the spend, and the customer is the last one to know. Your keys were on the platform. The platform is compromised. Your provider's billing email is not going to be the first thing to tell you.

The guard

The guard is one bash file with two commands. verify checks the static shape of how a key is configured to be used, before a run. window adds the two checks that need history: how many calls in the last day, and where they came from. It makes no network call. It reads a budget file and two log files that you maintain. It is not a secret manager and it does not try to replace one. It is a tripwire that sits between your code and the provider.

bash key_guard.sh verify --provider anthropic --job submit_claims

Paste that and read the output before anything else. This is the full guard as I run it:

#!/usr/bin/env bash
# key_guard.sh - pre-bill check for AI service keys (read only, no network)
# exit: 0 pass / 1 stop / 2 precondition missing (cannot decide)
set -euo pipefail

guard_root=${GUARD_ROOT:-$(cd "$(dirname "$0")" && pwd)}
cmd=${1:-verify}
shift || true

provider="" ; job="" ; count="" ; k1="" ; k2=""
while [ "$#" -gt 0 ]; do
  case "$1" in
    --provider|--job|--count)
      [ "$#" -lt 2 ] && { echo "FAIL: $1 needs a value" >&2; exit 2; }
      case "$1" in --provider) provider=$2 ;; --job) job=$2 ;; --count) count=$2 ;; esac
      shift 2 ;;
    *)
      if [ -z "$k1" ]; then k1=$1
      elif [ -z "$k2" ]; then k2=$1
      else echo "FAIL: too many positional args: $1" >&2; exit 2; fi
      shift ;;
  esac
done
[ -n "$provider" ] || { echo "FAIL: --provider is missing" >&2; exit 2; }
[ -n "$job" ] || { echo "FAIL: --job is missing" >&2; exit 2; }
now_s=$(date -u +%s) ; day_s=86400
up() { printf '%s' "$1" | tr 'a-z' 'A-Z'; }

# check 0: a key must not live as a plain env var in the runner.
check_source() {
  if printenv | grep -q -E '^(OPENAI|ANTHROPIC|AWS|GEMINI|GOOGLE|MISTRAL|OPENROUTER)_[A-Z_]*KEY='; then
    echo "FAIL source: key is set as a plain env var"; return 1
  fi
  case "$job" in *[!a-z0-9_]*) echo "FAIL source: job name must be lowercase, digits or underscore"; return 1 ;; esac
  echo "OK source: job=$job"; return 0
}

# check 1: project or workspace scope, plus an explicit model list.
check_scope() {
  local p scope models
  p=$(up "$provider")
  scope=$(printenv "${p}_SCOPE" || printf '')
  models=$(printenv "${p}_MODELS" || printf '')
  case "$scope" in project|workspace) ;; *) echo "FAIL scope: ${p}_SCOPE is not project or workspace"; return 1 ;; esac
  [ -z "$models" ] && { echo "FAIL scope: ${p}_MODELS is empty"; return 1; }
  echo "OK scope: $scope"; return 0
}

# check 2: budget file. one row per provider: provider unit month_cap run_cap
check_budget() {
  local f="$guard_root/budget.txt" row unit month_cap run_cap
  [ -f "$f" ] || { echo "FAIL budget: $f is missing"; return 1; }
  row=$(awk -v p="$provider" '$1==p {print; exit}' "$f")
  [ -n "$row" ] || { echo "FAIL budget: no row for $provider"; return 1; }
  unit=$(printf '%s' "$row" | awk '{print $2}')
  month_cap=$(printf '%s' "$row" | awk '{print $3}')
  run_cap=$(printf '%s' "$row" | awk '{print $4}')
  case "$unit" in USD|JPY) ;; *) echo "FAIL budget: unit must be USD or JPY"; return 1 ;; esac
  printf '%s' "$month_cap" | grep -q -E '^[0-9]+$' || { echo "FAIL budget: monthly cap is not an integer"; return 1; }
  printf '%s' "$run_cap"   | grep -q -E '^[0-9]+$' || { echo "FAIL budget: per run cap is not an integer"; return 1; }
  [ "$month_cap" -le 0 ] || [ "$run_cap" -le 0 ] && { echo "FAIL budget: a cap is zero or negative"; return 1; }
  echo "OK budget: $unit $month_cap / $run_cap"; return 0
}

check_unit_shape() {
  local f="$guard_root/budget.txt" bad
  bad=$(awk 'NF != 4 || $2 !~ /^(USD|JPY)$/ {print NR": "$0}' "$f")
  [ -n "$bad" ] && { echo "FAIL budget: rows without a single unit"; printf '%s\n' "$bad"; return 1; }
  echo "OK budget: all rows carry one unit"; return 0
}

# check 3: this run must fit the per-run budget row.
check_run_cap() {
  local f="$guard_root/budget.txt" row cap
  row=$(awk -v p="$provider" '$1==p {print; exit}' "$f")
  cap=$(printf '%s' "$row" | awk '{print $4}')
  [ "$count" -gt "$cap" ] && { echo "FAIL run_cap: $count is above the per run cap of $cap"; return 1; }
  echo "OK run_cap: $count within $cap"; return 0
}

# check 4: total calls recorded in the last 24h. log: "epoch provider tokens"
check_calls() {
  local f="$guard_root/calls.log" lo n24
  lo=$((now_s - day_s))
  [ -n "${AI_JOB_AUTO:-}" ] && printf '%s %s %s\n' "$now_s" "$provider" "$count" >> "$f"
  touch "$f"
  n24=$(awk -v lo="$lo" '$1 >= lo { s += $3 } END { printf "%d", s + 0 }' "$f")
  [ "$n24" -gt 280 ] && { echo "FAIL calls: $n24 in 24h, above the cap of 280"; return 1; }
  echo "OK calls: $n24 in 24h for $provider"; return 0
}

# check 5: home and datacenter from the same key is the tell. log: "epoch source key_id provider"
check_origin() {
  local f="$guard_root/origin.log" lo home dc
  lo=$((now_s - day_s))
  [ ! -f "$f" ] && { echo "FAIL origin: the source log is missing, start recording it per call"; return 2; }
  home=$(awk -v lo="$lo" -v p="$provider" '$1 >= lo && $4 == p && $2 == "home" {c++} END {printf "%d", c + 0}' "$f")
  dc=$(awk -v lo="$lo" -v p="$provider" '$1 >= lo && $4 == p && $2 == "dc" {c++} END {printf "%d", c + 0}' "$f")
  if [ "$home" -gt 0 ] && [ "$dc" -gt 0 ]; then
    echo "FAIL origin: home=$home dc=$dc in 24h for the same key"
    echo "       a grabber tests from home, then moves to a rented host"
    return 1
  fi
  [ "$home" -gt 0 ] && { echo "WARN origin: home=$home dc=0, no cloud side yet"; return 0; }
  echo "OK origin: home=0 dc=$dc"; return 0
}

# check 6: two keys with the same head and tail are usually one key copied under a new name.
check_pair() {
  local a b tail_n
  a=${1:-} ; b=${2:-} ; tail_n=${3:-8}
  [ -z "$a" ] || [ -z "$b" ] && { echo "SKIP pair: two keys are needed"; return 0; }
  [ "$a" = "$b" ] && { echo "FAIL pair: the two keys are identical"; return 1; }
  [ "${a:0:12}" = "${b:0:12}" ] && [ "${a: -$tail_n}" = "${b: -$tail_n}" ] && { echo "FAIL pair: head and tail match, likely one key copied under a new name"; return 1; }
  echo "OK pair: different keys"; return 0
}

case "$cmd" in
  verify)
    check_source || exit $?
    check_scope  || exit $?
    check_budget || exit $?
    check_unit_shape || exit $?
    [ -n "$k1" ] && [ -n "$k2" ] && { check_pair "$k1" "$k2" || exit $?; }
    ;;
  window)
    [ -n "$count" ] || { echo "FAIL: --count is missing" >&2; exit 2; }
    printf '%s' "$count" | grep -q -E '^[0-9]+$' || { echo "FAIL: --count must be an integer" >&2; exit 2; }
    check_source || exit $?
    check_scope  || exit $?
    check_budget || exit $?
    check_unit_shape || exit $?
    check_run_cap || exit $?
    check_calls  || exit $?
    check_origin || exit $?
    [ -n "$k1" ] && [ -n "$k2" ] && { check_pair "$k1" "$k2" || exit $?; }
    ;;
  *) echo "FAIL: unknown command: $cmd" >&2; exit 2 ;;
esac
echo "RESULT: PASS"

The three checks that are the actual point

check_source is the cheapest and the most violated. It greps the runner's environment for a provider key variable and fails if one is there. A key sitting in a plain environment variable is a key that was the thing Northflank's customers were storing in environment variables, and it is a key that any shell-level breach exfiltrates in one read. If your agent runner has PROVIDER_API_KEY exported, this check fails and that is correct.

check_scope requires two things to be declared: a scope of project or workspace, and an explicit model list. The reason is that a leaked key that can address every model and every project is a key that resells for more. Scope plus model list is what turns "a key" into "a key for this one job."

check_origin is the one that corresponds to the part of these incidents you cannot see on a billing page. The stolen-key resellers do not blast a key. They trickle it: a low, steady call rate that looks like a normal job, and they move it. The tell in the logs is a single key whose calls come from a home network and a data center within the same day. A legitimate job lives in one place. A key being tested from home and then moved to a rented host is not a legitimate job. That is the home-plus-datacenter condition, and it is the reason the guard logs the source per call instead of just the count.

Run it and read the output

Here is what the guard does against the three states I test every change, with a dummy budget row anthropic USD 50 5 and a dummy key that matches no real credential:

$ bash key_guard.sh window --provider anthropic --job submit_claims --count 3
OK source: job=submit_claims
OK scope: project
OK budget: USD 50 / 5
OK budget: all rows carry one unit
OK run_cap: 3 within 5
OK calls: 0 in 24h for anthropic
OK origin: home=0 dc=1
RESULT: PASS

$ # same key seen from home and a data center the same day
OK calls: 0 in 24h for anthropic
FAIL origin: home=1 dc=2 in 24h for the same key
       a grabber tests from home, then moves to a rented host

$ # a run that exceeds the per-run cap
OK run_cap: ... 
FAIL run_cap: 240 is above the per run cap of 5

Three things about how I use the output. First, a WARN line is not a pass you should wave through. WARN origin: home=1 dc=0, no cloud side yet means a home-side call was seen and there is no cloud side yet. That is often fine, but it is the state right before the fail, so I treat it as "check this today," not "ignore." Second, FAIL with exit 2 means the guard could not decide because a precondition is missing, for example the origin log does not exist yet. That is different from a FAIL with exit 1, which means it decided and the answer was stop. When the guard cannot decide, the safe behavior is to not run, so I wire 2 the same as 1 in the caller. Third, the guard is read-only and makes no network call, so you can run it in a CI step or a pre-commit hook without it phoning home, which is the property that matters when the point is that something out there already knows the key.

Where this is and is not the fix

This is not a replacement for rotation, and it is not a replacement for a secret manager. It is the check that runs in your own code in the window the platform's detection does not reach. Rotation fixes the next key. A secret manager fixes where the key lives. The guard fixes the moment the key is used, on your side, before the call leaves.

I built this while reading the October platform disclosures and noticing that every one of them told the customer the same line, "review your billing logs," and none of them could put a check on the customer's call path. The check does not need the platform's cooperation. It needs a budget row, two log files, and a guard that runs before the request. That is the part I could actually do this week, and the part that stays yours after the platform's incident page stops being the news.

The honest limits: the guard only sees the calls you route through it, so a key that is abused on a path you do not log is invisible to it. The 280-per-day call cap and the head-plus-tail key comparison are thresholds I picked from the shape of the reported abuse, not from any standard, and they are the first things to tune. And it catches the shape of abuse, not the existence of it. A slow, single-source, in-budget theft is a false negative by construction. I would rather have the three checks that catch the tested-and-moved pattern and the runaway-run pattern, with their false positives visible as WARN lines, than a dashboard that is quiet until the bill arrives.

References

  • Northflank security incident, disclosed October 30, 2026; timeline and customer guidance as reported by BleepingComputer, retrieved November 2, 2026.
  • Chainalysis security incident disclosure, published Friday, November 1, 2026, covering the use of "offensive agentic techniques" by an external actor; as reported by BleepingComputer, retrieved November 2, 2026.
  • Wiz, "Black October" for AI Developer Platforms, published October 30, 2026, covering a set of October breaches of AI development platforms and the profile of stolen AI keys, retrieved November 2, 2026. Treated as a second-source summary of the per-platform timeline; I did not independently verify each platform's own statement.

This post was written with AI assistance. The author is responsible for its content.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.