Engineering

Building a Kill Switch Before Letting Anyone Use My SaaS

YY Yonas Yeneneh May 22, 2026 12 min read

A few weeks before I launched ParseApi, I was lying awake doing the math on a worst-case AI bill.

Every extraction routes through a model that bills per token. A user could drag a hundred PDFs into a folder in thirty seconds. A bot could find an open endpoint and pound it. A bug in my routing layer could double-bill every request. None of these are theoretical — they're the boring failure modes that take down indie SaaS projects every week.

I'd seen friends wake up to four-figure Anthropic invoices because of a runaway loop they wrote on a Saturday night. I'd watched a former employer get a $40k surprise bill from a misconfigured Lambda. The pattern is always the same: by the time you notice, it's already done damage; by the time you can react, you've made it worse.

So before I let a single real user hit the app, I built a kill switch system. Not because I'm paranoid, but because I'm cheap and I've watched too many founders learn this lesson with their own money.

This post is what I built and why. The code is from the production repo.

The two failure modes I was actually scared of

I made a list of every way the app could quietly bleed money or trust:

  • Cost runaway — AI provider calls spiraling. Either a legit user with too many uploads, or a bad actor with a script.
  • Storage runaway — same idea, but R2 PUT requests instead of LLM tokens.
  • Auth abuse — sign-up spam, credential stuffing against API keys.
  • Bad deploy — I ship a regression that corrupts data on every write.

What ties them together: the right response in all four cases is the same thing — turn off the affected feature, fast, without redeploying. Every other action (notify users, refund, investigate) is downstream of that one decision.

So I needed a way to disable any single feature in under thirty seconds, from anywhere, without restarting the app. And I needed the system to do it automatically when I was asleep.

Three layers, in order of complexity

I ended up with three layers that work together:

  1. Feature flags — a single command surface for "turn this off"
  2. Tripwires — automated detectors that flip flags for me
  3. Fail-closed defaults — when in doubt, everything is off

You can build any one of these without the others, but they're much more useful as a stack. Let me walk through each.

Layer 1: feature flags as a single command surface

The first decision was: where do "off" switches live, and who can flip them?

The naive answer is environment variables — flip a value in your hosting dashboard, redeploy, done. That's fine for a setting you change once a quarter. It's terrible for a setting you might need to flip at 2am while a script is hammering you, because the redeploy takes a minute or two and you can't trust your CI pipeline to be in a known state.

So I built a flag table in Postgres, fronted by a tiny interface:

public interface IFeatureGate
{
    bool IsEnabled(string flag);
    Task<Result> SetAsync(string flag, bool newValue,
        Guid? actorUserId, string actorKind, string? reason, string? ipAddress,
        CancellationToken ct);
    Task<Result> SetManyAsync(IReadOnlyDictionary<string, bool> flags, /* ... */);
    IReadOnlyDictionary<string, bool> SnapshotAll();
}

Every flag is a string constant in one place:

public static class FeatureFlag
{
    public const string AiExtractionEnabled    = "AiExtractionEnabled";
    public const string UploadsEnabled         = "UploadsEnabled";
    public const string SignupsEnabled         = "SignupsEnabled";
    public const string PublicApiEnabled       = "PublicApiEnabled";
    public const string WebhookDeliveryEnabled = "WebhookDeliveryEnabled";
    public const string StripeWebhookEnabled   = "StripeWebhookEnabled";
    public const string OAuthSignInEnabled     = "OAuthSignInEnabled";
    public const string ReadOnlyMode           = "ReadOnlyMode";
    public const string StrictRateLimitMode    = "StrictRateLimitMode";
    public const string DefaultDenyWindow      = "DefaultDenyWindow";
    // ...
}

Notice two kinds: "enable flags" where the safe value is true, and "hazard flags" (ReadOnlyMode, StrictRateLimitMode, DefaultDenyWindow) where the safe value is false. Both directions matter — sometimes the safe response isn't disabling a feature, it's turning ON a degraded mode.

The flag check itself is one line in every gated entrypoint:

// UploadDocumentCommand.cs
if (!featureGate.IsEnabled(FeatureFlag.UploadsEnabled))
    return Result<DocumentDto>.Failure(new Error(
        "uploads_disabled",
        "Uploads are temporarily disabled. Please try again later."));
// StripeWebhookEndpoint.cs — note we ACK 200 to Stripe even when off,
// so they don't keep retrying while billing logic is suspect.
if (!featureGate.IsEnabled(FeatureFlag.StripeWebhookEnabled))
{
    logger.LogWarning("Stripe webhook received while disabled. Dropping.");
    return Results.Ok();
}

That's the whole user-facing surface. Eleven flags. Every dangerous-ish operation in the app is gated by at least one of them.

The admin console has eleven toggle switches that flip them, plus a single "PANIC" button that calls SetManyAsync with every enable-flag set to off and every hazard-flag set to on. Atomic. One row in the audit log. One email.

Layer 2: tripwires that flip flags for you

A kill switch is only useful if someone flips it. At 3am that someone is going to be a cron job, not you.

A tripwire is a small class that runs every 30 seconds, checks a single metric, and flips a flag if the metric crosses a threshold. The contract:

public interface ITripwire
{
    string Name { get; }
    bool Enabled { get; }
    Task<TripwireResult> EvaluateAsync(CancellationToken ct);
    Task OnTrippedAsync(TripwireResult result, CancellationToken ct);
}

The implementation I lose the most sleep over is the AI cost tripwire. It's literally six lines of logic:

public async Task<TripwireResult> EvaluateAsync(CancellationToken ct)
{
    decimal threshold = opts.CurrentValue.AiCostRate.MaxUsdPerHour;
    decimal spend = await q.SumAiCostLastHourAsync(ct);
    if (spend < threshold)
        return new TripwireResult(false, null);
    return new TripwireResult(true,
        $"AI cost rate ${spend:F2}/hr exceeded ${threshold:F2}/hr threshold");
}

public async Task OnTrippedAsync(TripwireResult result, CancellationToken ct)
{
    await gate.SetAsync(FeatureFlag.AiExtractionEnabled, false,
        null, "tripwire", result.Reason, null, ct);
    await gate.SetAsync(FeatureFlag.DemoEnabled, false,
        null, "tripwire", result.Reason, null, ct);
}

The threshold I picked is $3/hour. If the app suddenly burns that much, something is wrong — even with a thousand legitimate users active, my unit economics don't get that hot that fast. The cost of being wrong (extraction paused for 15 minutes while I look at the alert) is hilariously smaller than the cost of being right and not catching it (an open-ended invoice).

I now have seven tripwires running on the same 30-second tick:

  • AiCostRate — burn rate vs. threshold (the one above)
  • ErrorRate — fail rate of extraction jobs over a 5-minute window
  • SuspiciousIp — single IP making 200+ requests in 5 minutes
  • SignupSpike — too many signups from one /16 subnet
  • StorageGrowth — bytes added to R2 in an hour
  • ExtractionVolume — extractions per org per hour
  • FailedAuth — failed login attempts per IP

Each one is ~30 lines of code. Each one wires through the SAME IFeatureGate.SetAsync call as a manual flip. From the gated code's perspective there's no difference between "Yonas flipped this" and "the tripwire flipped this" — same audit row, same email, same cooldown rules.

The evaluator that ticks them is intentionally dumb:

foreach (ITripwire tw in tripwires)
{
    try
    {
        if (!tw.Enabled) continue;
        TripwireResult result = await tw.EvaluateAsync(ct);
        if (!result.Tripped) continue;
        logger.LogWarning("Tripwire {Name} TRIPPED: {Reason}", tw.Name, result.Reason);
        await tw.OnTrippedAsync(result, ct);
    }
    catch (Exception ex)
    {
        logger.LogError(ex, "Tripwire {Name} threw during evaluation.", tw.Name);
    }
}

One tripwire blowing up does not stop the others. This matters more than it sounds — early on I had a tripwire whose query was broken, and it was hiding the fact that another tripwire was firing correctly. Try-catch per iteration, no early return.

Layer 3: fail-closed defaults

The third layer is the boring one and also the one I trust the most.

If the Postgres connection drops, or the options snapshot fails to load, or the deployment is mid-restart, what does IsEnabled return? In a lot of systems the answer is "true" — because that's the optimistic default and it keeps the app responsive. That answer is wrong.

/// Returns the current value of a flag. Fail-closed: if the options
/// snapshot is null, returns the "safe" value (false for enable-flags,
/// true for hazard-flags), meaning every gated feature is OFF and every
/// protective mode is ON.
bool IsEnabled(string flag);

The interface itself encodes this. If anything goes wrong inside FeatureGate, every check returns the safe value. The app becomes unusable rather than dangerous. I will take "users complain that uploads are broken for two minutes" over "we processed 10,000 documents we can't bill for" every day of the year.

There's an Emergency DefaultDenyWindow flag that turns this up to eleven: when it's on, the global pipeline IP-denies anything that isn't on an explicit allowlist. It exists for the worst case — somebody is actively attacking the service and I need a hard stop while I figure out what's happening. Flipping it has a 6-hour cooldown specifically because I do NOT want to be able to turn it back off in the middle of an incident on impulse.

The bit that surprised me: cooldowns

I almost didn't build cooldowns. They felt fussy.

Then I role-played an incident in my head. The tripwire fires at 2am, AI extraction goes off, my phone wakes me up. I look at the dashboard, see one weird-looking customer, ban them, flip AI extraction back on. Five minutes later the tripwire fires again because the same weird-looking customer signed up with a new email. I flip it back on. Five minutes later. Five minutes later.

This is the classic flapping problem. It's the failure mode of every alerting system that lets you ack and dismiss without a forced pause.

So every flag has an asymmetric cooldown — only when you flip it in the re-enabling direction:

public static TimeSpan ReEnableCooldown(string flag) => flag switch
{
    AiExtractionEnabled    => TimeSpan.FromMinutes(15),
    UploadsEnabled         => TimeSpan.FromMinutes(5),
    DemoEnabled            => TimeSpan.FromMinutes(60),
    SignupsEnabled         => TimeSpan.FromMinutes(5),
    PublicApiEnabled       => TimeSpan.FromMinutes(15),
    ReadOnlyMode           => TimeSpan.FromMinutes(5),
    DefaultDenyWindow      => TimeSpan.FromHours(6),
    // ...
};

You can ALWAYS disable a feature instantly. You can NEVER instantly re-enable it. The 15 minutes for AI extraction isn't really 15 minutes for the system — it's 15 minutes for me, to actually look at what tripped instead of impulse-toggling it.

The 6-hour cooldown on DefaultDenyWindow is the most aggressive one in the system. If I turn that on during an incident, I am forced to live with denied traffic for 6 hours. That's the point. The "deny the world" switch should not be reversible in the middle of an adrenaline rush.

How it actually feels in production

The day I shipped all this, I deliberately ran my own AI cost up to test the tripwire. I created a folder, uploaded 80 sample documents in a row, and watched the dashboard.

Around the 70-document mark, the tripwire fired. AI extraction went off. The 71st document upload succeeded (uploads are a separate flag) but the extraction job sat there marked pending. My phone got an email. The flag stayed off for 15 minutes. After that I re-enabled it, the queued jobs ran, and life continued.

Total impact on me: a 15-minute pause while I confirmed the trip was real. Total impact on customers: zero, because there weren't any yet — but if there had been, they would have seen "extraction temporarily paused" rather than waited months for a refund of a bill I couldn't pay.

That's the whole pitch. The cost of building this system was maybe 2 weekends. The cost of the first incident that would have hit it pays it back forever.

What I'd do differently

Two things, in hindsight:

1. I should have built the admin console FIRST, before any of the flags. I built flags, then tripwires, then realized I had no UI to flip them and had to write the admin pages in a hurry. If I'd started with the console as a Hello-World page with eleven dead toggles, the rest would have been clearer.

2. Cooldowns should have been the first thing, not the last. I almost shipped without them, and the version of me 30 days after launch would have hated past-me for it. The first incident you don't have cooldowns for, you'll flap. Guaranteed.

Takeaways for your own kill switch

If you take three things from this post:

  1. Flags are the foundation, not the feature. Build a single IsEnabled(flag) check and put it in every dangerous code path before launch. You don't need a fancy library — a Postgres table and an interface gets you 95% there.

  2. Tripwires beat alerting. A page in PagerDuty saying "AI cost is high" is useless at 3am. A tripwire that has already flipped the switch by the time you're awake is the actual goal. Alerting is informational; tripwires are corrective.

  3. Fail-closed isn't a defense; it's a default. Decide what "off" looks like for every flag and make that the answer when everything else fails. The cost of being too cautious in a degraded state is so much lower than the cost of being optimistic.

The pre-launch fear that drove me to build this was correct. The first month of operating with it has only validated that. If you're running a SaaS that can spend money on your behalf — and basically every modern SaaS can, between AI calls, S3 puts, webhook fanouts, transaction emails — you want this layer in place before you go live, not after the first incident teaches you why.

The code in this post is from the production repo. If you'd like to see how the pieces wire together — the FeatureFlag enum, the tripwire evaluator, the admin console — the repo is open at github.com/yonas/parseapi.dev. Steal whatever's useful.

Try ParseApi free

100 pages per month at no cost. No credit card required.

Get started