RSS Amplifier

Now I Get It! Blog · May 1, 2026

TIME BOMB BUG!!

0
Sign in to vote or save

John Damask · nowigetit.us

A few posts back I mentioned a thumbnail-403 mystery during the gallery-UX smoke test on April 27 and chalked it up to a benign cookie expiry. I was wrong. That was the first visible tremor of a latent bug whose epicenter was a scheduled CloudFront key rotation that had fired the same afternoon and half-completed silently. Today, four days later, I caught up to it -- after I'd already mistakenly blamed another deploy for the same symptoms, and roughly an hour before the prod equivalent of the same bomb was scheduled to go off.

How it surfaced

I was smoke-testing a small content-filter error-message change on test, signed in fresh, and tried to view a private paper. CloudFront returned 403 InvalidKey: Unknown Key as a raw S3-style XML error. My Gallery thumbnails 403'd. Click-through to any private paper rendered the same XML. The job processing pipeline itself was fine -- the DynamoDB row showed status=complete, the generated HTML was sitting in S3 at the expected key -- so the failure was purely at the CloudFront viewer-auth layer.

My first read was that the deploy I'd just done broke something. It hadn't. Sign-out / sign-in / hard refresh all reproduced the failure on every fresh cookie issuance, which was the giveaway: the auth gate itself was rejecting newly-minted cookies.

What the system is supposed to do

Private-page protection on this app is CloudFront signed cookies. Four moving parts:

  1. An RSA private key in Secrets Manager. The auth Lambda uses it to sign cookie policies on user login or refresh.
  2. The matching public key registered in CloudFront, with an Id like K-NEW.
  3. A CloudFront KeyGroup whose Items array lists which public-key Ids are trusted. The pages/users/* cache behavior says "only allow viewers whose cookies are signed by a key in this group."
  4. An SSM parameter telling the auth Lambda which Key-Pair-Id to stamp into cookies.

A scheduled Lambda runs every 30 days via an EventBridge rule. It's supposed to atomically generate a new keypair, register the new public key in CloudFront, add it to the KeyGroup, write the new private key into Secrets Manager, write the new Key-Pair-Id into SSM, then remove the old key from the KeyGroup and delete the old public key. Seven steps -- atomic in intent, not in implementation.

Three layered bugs

The first scheduled rotation in test fired on April 27 at 12:27 UTC -- exactly thirty days after the original bootstrap. CloudWatch logs from the rotation Lambda that afternoon:

Created CloudFront PublicKey: K-NEW
Rotated: K-OLD -> K-NEW
END  Duration: 1638 ms

No errors logged. Was anything but.

Bug 1: an IAM gap. The Lambda role has an inline policy granting ssm:GetParameter on exactly one parameter -- the one that stores the active Key-Pair-Id. The sibling parameter, the one that stores which KeyGroup the rotation Lambda is supposed to mutate, is not in any policy on the role. The rotation Lambda needs both. Most likely either policy merging missed it when the feature shipped, or the param was added later than the IAM was hardened. Either way, the IAM has been wrong since the system was built. It just hadn't fired the bad code path yet.

Bug 2: a silent exception swallow. The rotation Lambda reads that SSM parameter inside a bare try / except Exception: pass. No log, no metric, no re-raise. The AccessDeniedException from the IAM gap vanished into nothing, and the module-level KEY_GROUP_ID global stayed at its empty-string default.

Bug 3: guarded steps fail-open. Three KeyGroup operations -- add the new key, remove the old key, delete the old public key -- are all gated by if KEY_GROUP_ID:. With the global empty, all three silently no-op'd. Worse, the print("Rotated: old -> new") statement that produced the deceptively-clean log line runs before any of the gated steps, so the log line is meaningless: it confirms only that control reached that point, not that anything happened afterwards.

What actually executed: new public key registered in CloudFront ✓, secret in Secrets Manager overwritten with the new private key ✓, SSM Key-Pair-Id pointer flipped to the new Id ✓, KeyGroup membership untouched ✗, old public key still registered in CloudFront ✗.

The auth Lambda was now signing cookies with the new Key-Pair-Id. The KeyGroup still trusted only the old one. Every cookie validation failed with InvalidKey: Unknown Key. CloudFront's normal error string is MissingKey when no Key-Pair-Id is present at all, and InvalidKey when one is present but doesn't match a trusted key. That transition -- MissingKey for an unauthenticated request, InvalidKey for an authenticated one -- is exactly the textbook signature of cookies being signed by a key the trusted group doesn't know.

Why CloudFormation had been masking this for a month

The CFN template declares the KeyGroup with Items: [!GetAtt BootstrapSigningKey.PublicKeyId]. CloudFormation populates the KeyGroup membership directly from the bootstrap custom resource's return value -- it does not delegate to the Lambda's imperative path. So every prior key creation in this codebase had been a CloudFormation event (the original bootstrap and a stack restructure), and every one of them had its KeyGroup correctly populated by CloudFormation itself. The Lambda's broken _add_to_key_group path had never executed successfully, and nobody had noticed because nothing had needed it to.

Only the EventBridge-scheduled rotation goes through the Lambda's imperative path. The first scheduled fire is exactly when the bug becomes visible.

The thumbnail red herring, revisited

The April 27 gallery-UX deploy ran a CloudFront invalidation late that afternoon. The thumbnail 403s I saw immediately afterward, and dismissed as expired cookies, were almost certainly the rotation's first knock. The "re-sign-in restored them" observation is probably not what I thought it was: the re-sign-in issued cookies signed with the new untrusted Key-Pair-Id, which CloudFront also wouldn't validate. The likelier explanation is that I'd been looking at warm-cache cookies that briefly expired during the invalidation churn, then a previously-warm Lambda container kept serving valid old-key cookies for a while longer. Either way, I didn't dig in, because the surface story (signed-cookie expiry on a feature that didn't touch auth) was plausible enough to move on. Cost of an unexplored anomaly: four days, and very nearly a customer-facing prod incident.

Customer impact propagation

This is not multi-account-scoped. It hits every authenticated user. Cognito access tokens default to a 1-hour TTL, the frontend's authFetch auto-refreshes when it sees an expired token, and the refresh path returns fresh signed cookies that the frontend writes over document.cookie. Page reloads go through the same path. Fresh sign-ins go through the same path. All three re-issue cookies signed with whatever Key-Pair-Id is currently in use. Cookie Max-Age is 30 days but cookies get overwritten within ~1 hour of any active session.

So within roughly an hour of a broken rotation, every active user's cookies have been re-signed with the untrusted Key-Pair-Id, and every viewer request to a private page starts coming back as 403 InvalidKey. API calls (which authenticate via the Cognito JWT in the Authorization header, not signed cookies) keep working. Public pages keep working. Only pages/users/* breaks: gallery thumbnails, click-through to any private paper.

There is one safety net I didn't initially appreciate, surfaced by an Opus phone-a-friend review of the diagnosis: the auth Lambda treats the secret as the source of truth for Key-Pair-Id, not SSM. And it caches the private key + Key-Pair-Id at module scope across warm invocations. So after a rotation, only cold-start auth invocations begin issuing broken cookies. Warm containers continue signing with cached old values for somewhere in the 5-to-60-minute range, depending on activity. That delay buffer means a fast operator who notices a half-rotation has a small window to fix the KeyGroup membership before any user sees the failure. None of which I had been watching for.

What this meant for prod

Same code, same CloudFormation, same IAM topology. The prod EventBridge rule had fired exactly once since launch, the bootstrap on April 1. rate(30 days) schedules from previous-fire time, so the predicted next fire was May 1 at 12:27 UTC -- which was today, when this was discovered. EventBridge metrics showed no fire yet. So prod was sitting an unknown small number of hours away from reproducing the test failure for every authenticated user, including the 1-paper-per-week trickle of real customers who would have logged in over the following weekend and seen InvalidKey instead of their papers.

Defusing prod

The cheapest defuse for prod is to disable the EventBridge rule. One CLI call: aws events disable-rule --name nowigetit-key-rotation-schedule. Fully reversible, no code change, no IAM change, no CloudFormation deploy. Prod can no longer auto-fire the rotation Lambda; the underlying bugs are still there, but with the trigger removed they cannot harm anyone. State change verified via events describe-rule. One caveat I noted in passing for a future me: when the rule is eventually re-enabled, EventBridge's behavior on past-due rate(...) schedules is to fire shortly after re-enable rather than waiting another full 30 days -- so the IAM and code fixes need to land before the re-enable.

To unblock the test environment for the smoke test that surfaced this in the first place, I added the new key to the test KeyGroup directly via aws cloudfront update-key-group, keeping the old key for grace in case any cached old cookies were still in flight. CloudFront edge propagation took a couple of minutes; after that, fresh logins to test produced cookies that validated, and the smoke test passed.

Process: phone-a-friend before recommending a prod action

Before committing to a fix path on a customer-facing system, I ran the analysis through an Opus extended-thinking second-opinion review (the phone-a-friend skill). The friend independently grounded against live AWS state in both accounts, the CFN template, the rotation Lambda, the auth Lambda, the frontend auth flow, and the EventBridge metrics. It confirmed every load-bearing claim in the diagnosis. It surfaced three useful refinements:

  • Timing was sharper than I'd framed it. The first prod fire was 12:27:09 UTC, not 12:00. The clock was running tighter than I'd been treating it.
  • The IAM patch alone is necessary but not sufficient. The order-of-operations inside the rotation Lambda mutates the secret and SSM before verifying the KeyGroup membership change, so any future failure of the KeyGroup operation -- transient CloudFront API error, throttling, anything -- reproduces the same brick. The right code fix is to reorder, and to add a positive post-rotation assertion that the new key is in the trusted group and the old one is not.
  • The right opening move is to disable the EventBridge rule first, eliminating any race between an in-flight IAM patch and a scheduled fire. I'd been planning to lead with the IAM patch and re-enable; leading with disable is strictly safer and just as cheap.

After-action notes

A scheduled job whose error path is try / except Exception: pass is a known time bomb. A success log only confirms the routine reached its log line, not that post-conditions hold -- the code fix has to add a positive assertion at the end of the routine, not just an absence of errors. CloudFormation automation that handles a critical step (here, populating the KeyGroup directly via Items: [!GetAtt ...]) can mask a deep bug in the imperative path used by schedulers; the bug only fires when nobody's watching. Secrets Manager AWSPREVIOUS retains exactly one prior version within a 24-hour window, so two consecutive bad rotations in a day would make rollback meaningfully harder. The unintentional safety net was that long-lived warm Lambda containers plus module-level caches provide a useful detection window after a broken rotation -- but only if there's a monitor watching for the drift, which is what the code-fix phase needs to add.

Bug filed with full RCA and a five-phase remediation plan: prod safety via disabling the rule (done); IAM patch on both accounts; restore test (done); code fix PR; re-enable prod after validation. Phases 2, 4, and 5 are deferred to the next session. The prod rule stays disabled in the meantime.

If I had checked the thumbnail anomaly on April 27 instead of moving on, I'd have caught this with three days of slack. Always check anomalies, especially when the surface story makes sense.

Read the original on nowigetit.us

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.