<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Alon Trifonov</title>
    <description>The latest articles on DEV Community by Alon Trifonov (@apex97).</description>
    <link>https://dev.to/apex97</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4209559%2Ffb90368b-55ac-474a-9bd3-83f64f9b66d7.png</url>
      <title>DEV Community: Alon Trifonov</title>
      <link>https://dev.to/apex97</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/apex97"/>
    <language>en</language>
    <item>
      <title>I Planned a Multi-Agent Coding System. Here Are the 7 Decisions That Matter</title>
      <dc:creator>Alon Trifonov</dc:creator>
      <pubDate>Thu, 29 Oct 2026 14:44:58 +0000</pubDate>
      <link>https://dev.to/apex97/i-planned-a-multi-agent-coding-system-here-are-the-7-decisions-that-matter-38kp</link>
      <guid>https://dev.to/apex97/i-planned-a-multi-agent-coding-system-here-are-the-7-decisions-that-matter-38kp</guid>
      <description>&lt;p&gt;At Zyntari, my engineering studio in Israel, I am building a system where multiple AI coding agents work on real software projects: taking tasks, writing code, running tests, opening pull requests, and waiting for approvals. Before writing code, I wrote a full technical specification with every architecture decision, every tradeoff, and every thing we deliberately will not build.&lt;/p&gt;

&lt;p&gt;A spec is usually private. But the decisions in it apply to anyone trying to run more than one coding agent on anything real, so here are the seven that matter most, why we made them, and how to steal them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp1f1c317jq4bgyxr8yrg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp1f1c317jq4bgyxr8yrg.png" alt="Architecture diagram" width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The short version of the whole philosophy: agents are allowed to reason, but they are not allowed to decide what is true. The system owns the truth. That single rule drives almost everything below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 1: A state machine runs the show, not a conversation
&lt;/h2&gt;

&lt;p&gt;Most agent systems today are a chat that remembers things. The agent narrates what it is doing, and whatever it says becomes the state of the project. That works until the agent gets confused, contradicts itself, or the process dies mid-sentence.&lt;/p&gt;

&lt;p&gt;Our rule: task state lives in PostgreSQL, and agents can only request state changes. A task moves through explicit states (proposed, planned, running, awaiting_review, merging, completed), and transitions happen through one pure function that either returns the new state with the events it emits, or returns a rejection.&lt;/p&gt;

&lt;p&gt;No caller, human or agent, updates state directly in the database.&lt;/p&gt;

&lt;p&gt;An agent finishing its work does not get to say "done." It must return structured output validated against a schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const AgentResultSchema = z.object({
  status: z.enum(["completed", "blocked", "failed", "needs_review"]),
  summary: z.string().max(4000),
  commitSha: z.string().regex(/^[0-9a-f]{40}$/).optional(),
  changedFiles: z.array(z.string()).max(500),
  acceptanceCriteria: z.array(z.object({
    id: z.string(),
    status: z.enum(["passed", "failed", "not_tested"]),
    evidenceRefs: z.array(z.string()),
  })),
});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important line is the design rule behind it: a model saying status: "completed" is not a verdict. It is a request to be verified. Tests, reviews, and evidence decide completion. Prose never can.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 2: Temporal owns durability, and determinism is non-negotiable
&lt;/h2&gt;

&lt;p&gt;An agent run is not a web request. It can take twenty minutes, wait two days for a human approval, and survive a crashed server. You can hand-roll that, or you can use a workflow engine built for exactly this. We chose Temporal, and Temporal's own documentation describes the same shape we use: the agent loop is a Workflow, each model call and tool call is an Activity, and a crash resumes the conversation instead of restarting it.&lt;/p&gt;

&lt;p&gt;That architecture comes with one strict rule, and it is the rule people break first: workflow code must be deterministic, because Temporal replays it from history after failures. So no LLM calls, database queries, Git commands, or random IDs inside workflow code. &lt;/p&gt;

&lt;p&gt;All side effects live in Activities, which are recorded once and replayed from history. The moment you put a direct API call inside a workflow, recovery stops working and you will not notice until the first crash.&lt;/p&gt;

&lt;p&gt;For long agent runs, Activities heartbeat their progress: which session is running, which phase it is in, and how much it has spent so far. A heartbeat timeout is how you learn that a Claude process or a whole host died, instead of finding out from a silent dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 3: Drive agents with an SDK, not terminal keystrokes
&lt;/h2&gt;

&lt;p&gt;The obvious way to automate a coding agent is to run it in a terminal and script the keystrokes. It is also a dead end: no structured events, no reliable cost data, no way to resume a session after a crash.&lt;/p&gt;

&lt;p&gt;The &lt;a href="//code.claude.com/docs/en/agent-sdk"&gt;Claude Agent SDK&lt;/a&gt; exposes the same agent loop that powers Claude Code programmatically, in TypeScript and Python: messages, session IDs, tool permissions, hooks, budget limits, and structured output. &lt;/p&gt;

&lt;p&gt;Every run records its session ID before it is allowed to touch anything, so a crashed run can be resumed instead of restarted. Hooks let us enforce policy in the host process before any tool executes, which matters because a policy check does not consume model context and cannot be talked out of by a prompt.&lt;/p&gt;

&lt;p&gt;One detail worth copying even in small projects: model recommendations in tool metadata, things like "this tool is read-only," are hints for scheduling and display, not security controls. Enforce permissions yourself, in code, before execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 4: One Git worktree per task, and a sandbox around the worker
&lt;/h2&gt;

&lt;p&gt;Two agents should never share a writable checkout. Every implementation task gets its own Git worktree, and the system enforces a conservative write set: the paths the task owns, plus the files those paths implicate, like lockfiles and shared configuration. Tasks with overlapping write sets do not run at the same time. Lockfile changes and database migrations take a repository-wide lock, because those are the changes that break everyone else's in-flight work.&lt;/p&gt;

&lt;p&gt;Around the worker process, a boring security profile on Linux: rootless Docker, non-root user, write access only to its own worktree, no Docker socket, a default-deny network with an allowlist for exactly the endpoints it needs, and &lt;a href="//docs.docker.com/engine/security/seccomp/"&gt;a default seccomp profile&lt;/a&gt; that is never disabled for convenience. Nothing here is exotic. &lt;/p&gt;

&lt;p&gt;It is the same discipline you would apply to any process that executes machine-generated code, which is what an agent is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 5: The scheduler is an algorithm, not a mood
&lt;/h2&gt;

&lt;p&gt;When you have many tasks and a few agents, someone has to decide what runs next. The tempting 2026 answer is "let an agent decide." Ours does not.&lt;/p&gt;

&lt;p&gt;The task graph is validated deterministically: every dependency exists, no cycles (Kahn's algorithm, cheap and absolute), every objective maps to an acceptance criterion. Priority is a weighted formula combining business priority, critical path, wait age, and expected cost. Agents may classify inputs, like labeling a task as security-sensitive, but those classifications are constrained enums with a confidence score. &lt;/p&gt;

&lt;p&gt;The final number comes from the formula.&lt;/p&gt;

&lt;p&gt;Why so rigid? Because a scheduler that is a formula can be tested, logged, and debugged. A scheduler that is an LLM judgment call cannot answer the question "why didn't my urgent task run" without another LLM judgment call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 6: Classify failures before you retry anything
&lt;/h2&gt;

&lt;p&gt;Blind retry is how a small problem becomes a large bill. We classify every failure first, and each class gets a different response:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure class&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Response&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Transient&lt;/td&gt;
&lt;td&gt;network reset, provider 5xx&lt;/td&gt;
&lt;td&gt;Retry with backoff and jitter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rate limit&lt;/td&gt;
&lt;td&gt;quota hit&lt;/td&gt;
&lt;td&gt;Wait until the supplied reset time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker loss&lt;/td&gt;
&lt;td&gt;crashed process&lt;/td&gt;
&lt;td&gt;Resume the session, or start a clean attempt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validation&lt;/td&gt;
&lt;td&gt;malformed output&lt;/td&gt;
&lt;td&gt;One repair attempt, then fail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deterministic&lt;/td&gt;
&lt;td&gt;reproducible compile error&lt;/td&gt;
&lt;td&gt;Send back for replanning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Policy violation&lt;/td&gt;
&lt;td&gt;forbidden command&lt;/td&gt;
&lt;td&gt;Never retry. Escalate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Budget reached&lt;/td&gt;
&lt;td&gt;cost ceiling hit&lt;/td&gt;
&lt;td&gt;Pause and ask for a decision&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Backoff is exponential with full jitter, and automatic retries stop at two. If three attempts failed, the task definition is the problem, not luck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 7: Budgets and fencing tokens, because agents are processes that spend money
&lt;/h2&gt;

&lt;p&gt;Every run reserves predicted cost from its parent budgets before it starts, and reconciles actual cost when it ends. Circuit breakers pause automatic spawning when a project reaches 80% of daily budget, when an attempt costs twice its prediction, or when token burn rate spikes. &lt;/p&gt;

&lt;p&gt;I wrote about this pattern for regular applications in a previous article about spending limits; in a multi-agent system it stops being a best practice and becomes load-bearing, because the system can spend money while you sleep by design.&lt;/p&gt;

&lt;p&gt;And one classic distributed-systems lesson that agent systems rediscover the hard way: fencing tokens. Every lease on a task or worktree carries a number that only goes up. If a worker's lease expires and the task is handed to someone else, late results from the old worker are rejected even if it wakes up and politely reports success. &lt;/p&gt;

&lt;p&gt;Without this, two workers eventually write to the same place, and they will do it while everything looks healthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we deliberately did not build
&lt;/h2&gt;

&lt;p&gt;The spec has a longer section on non-goals than on most features: no Kubernetes, no Kafka, no Redis, no vector database, no learned routing model in version one, and no agents sharing a writable checkout. Temporal already provides durable queues, timers, and retries. PostgreSQL provides transactional state. &lt;/p&gt;

&lt;p&gt;Every additional component is a bottleneck you have not measured yet, dressed as preparation.&lt;/p&gt;

&lt;p&gt;The same discipline applies to agent memory: versioned Markdown files with PostgreSQL full-text indexes, not an embeddings stack, until keyword retrieval demonstrably fails. Code memory is dominated by exact symbols, paths, and commands, where lexical search usually beats semantic similarity anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to steal this
&lt;/h2&gt;

&lt;p&gt;If you are building anything in this space, the order matters more than the parts. Build the deterministic skeleton first, state machines and event logs, and prove that killing every process neither loses nor duplicates a task. Then add the agent runtime. Then the operator surface. Only after one real bug travels from intake to a protected-branch merge with zero manual copying do you earn a second department of agents.&lt;/p&gt;

&lt;p&gt;Agents reason. The system decides. That is the entire architecture in one sentence.&lt;/p&gt;




&lt;p&gt;Alon Trifonov is the founder of Zyntari (zyntari.com), an independent engineering studio in Israel that builds production-grade software and AI systems. He works in Python, TypeScript, and everything that has to keep running after the demo ends. &lt;a href="https://www.linkedin.com/in/alon-trifonov-546061234" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;  &lt;a href="https://github.com/A-Pex97/" rel="noopener noreferrer"&gt;Github&lt;/a&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>architecture</category>
      <category>ai</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Nobody Prompts for a Spending Limit: How to Stop Your AI App From Burning Money Overnight</title>
      <dc:creator>Alon Trifonov</dc:creator>
      <pubDate>Wed, 28 Oct 2026 12:51:40 +0000</pubDate>
      <link>https://dev.to/apex97/nobody-prompts-for-a-spending-limit-how-to-stop-your-ai-app-from-burning-money-overnight-59e9</link>
      <guid>https://dev.to/apex97/nobody-prompts-for-a-spending-limit-how-to-stop-your-ai-app-from-burning-money-overnight-59e9</guid>
      <description>&lt;p&gt;A client of mine once left an automated AI app running unsupervised for one day. The app generated websites end to end: every job triggered AI calls, the AI calls triggered more jobs, and nothing anywhere in the system capped how many could run at once, how much the app was allowed to spend, or how to make it stop.&lt;/p&gt;

&lt;p&gt;One day later: an AI bill of over $1,000. The app worked exactly as built. Nobody had built the brakes.&lt;/p&gt;

&lt;p&gt;I'm Alon Trifonov, founder of Zyntari, a small engineering studio in Israel. I take AI-generated prototypes and turn them into production software, and this story is the quiet failure I see most often. Not a hack, not a bug that crashes anything. Just an app doing its job with no limit on what that job costs.&lt;/p&gt;

&lt;p&gt;The industry is waking up to the same problem. Gartner predicts &lt;a href="//gartner.com/en/newsroom/press-releases/2025-06-25-gartner-predicts-over-40-percent-of-agentic-ai-projects-will-be-canceled-by-end-of-2027"&gt;over 40% of agentic AI projects will be canceled by the end of 2027&lt;/a&gt;, citing escalating costs and inadequate risk controls. And in September 2026 they added a newer, sharper warning: by 2030, 80% of organizations with public-facing AI will experience a "&lt;a href="//gartner.com/en/newsroom/press-releases/2026-09-15-gartner-unveils-top-strategic-predictions-for-2027-and-beyond"&gt;cost exhaustion attack&lt;/a&gt;", where someone deliberately drives up your AI usage to inflate your bill. So this is not only about your own code looping. Strangers can burn your budget on purpose, through the public features you shipped.&lt;/p&gt;

&lt;p&gt;Here is how to build the brakes, in five layers, with code. Everything below is Python and Flask flavored, but the patterns work in any stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: Set the hard limit at your provider (do this today)
&lt;/h2&gt;

&lt;p&gt;This takes five minutes, requires zero code, and is the single highest-value step in this article.&lt;/p&gt;

&lt;p&gt;&lt;a href="//developers.openai.com/api/docs/guides/spend-limits"&gt;OpenAI supports monthly spend limits&lt;/a&gt; at both the organization and project level. Go to your organization settings, open Limits, set a monthly spend limit, and this is the important part: turn on "Enforce a hard limit." Without that toggle, the limit only sends you alerts while requests keep flowing. With it, API calls start failing when you hit the number, which is exactly what you want at 3am. Anthropic offers the same idea for Claude, with organization and workspace limits in the Console.&lt;/p&gt;

&lt;p&gt;Set the number to what you can actually afford to lose in a bad month. Not what you expect to spend. What you can afford.&lt;/p&gt;

&lt;p&gt;One warning: provider limits are your last line of defense, not your only one. They reset monthly, they apply to everything under the project, and when they trip, your feature just dies. You want your own code to slow down and stop gracefully long before the provider pulls the plug. That is what the next layers are for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: Know what every call costs
&lt;/h2&gt;

&lt;p&gt;You cannot cap what you do not measure. Most generated code treats an AI call as free: send prompt, get answer, move on. The fix is a small pricing table and a cost log on every single call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;MODEL_PRICES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-4o&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-4-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;# prices per 1M tokens, in USD cents.
# Check your provider's pricing page and keep this current.
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call_cost_cents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;prices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MODEL_PRICES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;input_cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_tokens&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;prices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;output_cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completion_tokens&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;prices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_cost&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;output_cost&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every API response already carries the token counts in response.usage. Wrap your AI calls so each one logs the model, the tokens, and the cost in cents to a table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AIUsage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Integer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utcnow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;feature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;cost_cents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Integer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The feature column matters more than it looks. When the bill jumps, this is how you answer "which part of my app spent it" in one query instead of one week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: A budget check before every call
&lt;/h2&gt;

&lt;p&gt;Now the actual brake: before any AI call, ask "are we allowed to spend this today?"&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;DAILY_BUDGET_CENTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AI_DAILY_BUDGET_CENTS&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# $20
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BudgetExceeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_daily_budget&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcnow&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;spent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AIUsage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cost_cents&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AIUsage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalar&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;spent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;DAILY_BUDGET_CENTS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;BudgetExceeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Daily AI budget reached: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;spent&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; cents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then wrap every AI call in one function that checks before and records after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;guarded_ai_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;check_daily_budget&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_cost_cents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AIUsage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;feature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cost_cents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In your routes, catch BudgetExceeded and return something graceful: a "this feature is paused until tomorrow" message beats a crashed page and beats a surprise invoice. Every AI call in the app goes through this one wrapper. No exceptions, no "just this once" direct calls in a controller somewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: Cap concurrency and retries
&lt;/h2&gt;

&lt;p&gt;This is the layer that would have saved my client their $1,000 day. Their app had no limit on how many jobs could run at the same time, so one busy morning multiplied itself.&lt;/p&gt;

&lt;p&gt;Two knobs handle most of it.&lt;/p&gt;

&lt;p&gt;First, worker concurrency. If you run background jobs with Celery, the worker flag is the cap:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;celery -A app.celery worker --concurrency=4&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Four workers means at most four AI jobs burn money at the same moment. Set it from your budget, not from your CPU count.&lt;/p&gt;

&lt;p&gt;Second, retry discipline. Retry logic is where quiet bills hide, because a retrying loop is an AI call inside a loop. If you use tenacity, cap it hard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;tenacity&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stop_after_attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_exponential&lt;/span&gt;

&lt;span class="nd"&gt;@retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stop&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;stop_after_attempt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;wait_exponential&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;guarded_ai_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;feature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;site-builder&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-4o&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three attempts with exponential backoff. Never retry_forever on anything that costs money per attempt. If it failed three times, the fourth try is not your biggest problem anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 5: A kill switch you can hit from your phone
&lt;/h2&gt;

&lt;p&gt;Budgets slow the bleeding. Sometimes you need to stop it completely, fast, without a deploy. Add a switch that every AI feature respects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ai_enabled&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AI_FEATURES_ENABLED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# inside guarded_ai_call, before anything else:
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;ai_enabled&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AI features are disabled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now stopping every AI feature in your product is one environment variable change on your host, which takes thirty seconds from the Render or Railway dashboard on your phone. Pair it with an alert before you need it: when daily spend crosses 80% of budget, send yourself a message. A Slack webhook or even a plain email works. The alert is what turns "one unsupervised day" into "one weird hour I caught at lunch."&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;Run this against any app that calls an AI model:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Hard spend limit set at the provider, enforced, not just alerting&lt;/li&gt;
&lt;li&gt;Every AI call logged with model, feature, and cost in cents&lt;/li&gt;
&lt;li&gt;A daily budget check that refuses calls before they happen&lt;/li&gt;
&lt;li&gt;Worker concurrency capped, retries capped at three&lt;/li&gt;
&lt;li&gt;A kill switch you can flip without deploying&lt;/li&gt;
&lt;li&gt;An alert at 80% of budget, so you hear about problems the same hour&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is exotic engineering. It is an afternoon of work. But it is also invisible, and invisible work is exactly what AI builders skip, because nobody types "and please add a spending limit" into a prompt box. The model builds what you ask for. The brakes are on you.&lt;/p&gt;

&lt;p&gt;If you want the bigger picture around this, I wrote an honest guide to &lt;a href="//zyntari.com/guides/how-to-build-an-app-with-ai"&gt;what building an app with AI actually involves in 2026&lt;/a&gt;, including where the demo ends and the real work starts. And if your app also needs its security holes closed, that checklist is in &lt;a href="https://dev.to/apex97/5-security-holes-almost-every-ai-generated-app-ships-with-and-how-to-fix-them-1lj7"&gt;my previous article&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;Alon Trifonov is the founder of Zyntari (zyntari.com), an independent engineering studio in Israel that turns prototypes and AI-generated apps into secure, production-grade software. He builds with Python, Flask, and React, with security and reliability as defaults rather than afterthoughts. LinkedIn: &lt;a href="https://www.linkedin.com/in/alon-trifonov-546061234/" rel="noopener noreferrer"&gt;https://www.linkedin.com/in/alon-trifonov-546061234/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>python</category>
      <category>llm</category>
    </item>
    <item>
      <title>5 Security Holes Almost Every AI Generated App Ships With (And How to Fix Them)</title>
      <dc:creator>Alon Trifonov</dc:creator>
      <pubDate>Tue, 27 Oct 2026 11:40:13 +0000</pubDate>
      <link>https://dev.to/apex97/5-security-holes-almost-every-ai-generated-app-ships-with-and-how-to-fix-them-1lj7</link>
      <guid>https://dev.to/apex97/5-security-holes-almost-every-ai-generated-app-ships-with-and-how-to-fix-them-1lj7</guid>
      <description>&lt;p&gt;AI app builders like Lovable, Bolt, and v0 are impressive. You describe what you want, wait a few minutes, and get a working app that looks ready for real users.&lt;/p&gt;

&lt;p&gt;It is not ready for real users.&lt;/p&gt;

&lt;p&gt;I'm Alon Trifonov, founder of Zyntari, a small engineering studio in Israel. A big part of my job is taking AI-generated prototypes and turning them into production software, mostly Python and Flask on the backend. After auditing a bunch of these apps, I keep finding the same five security holes. Not exotic, movie-hacker stuff. The boring classics: the kind that show up in the OWASP Top 10 year after year because developers keep making the same mistakes. Now AI generators are making those same mistakes at scale.&lt;/p&gt;

&lt;p&gt;Every one of them is fixable in under an hour. Here is what to look for, how to check if your app has the problem, and exactly how to fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Debug mode left on
&lt;/h2&gt;

&lt;p&gt;Open the main file of your generated app. If you see this, you have a problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;debug&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it's dangerous:&lt;/strong&gt; debug=True enables the Werkzeug debugger, an interactive Python console that runs in your browser. If it ever reaches a live server, anyone who triggers an error in your app gets a console that can run arbitrary code on your machine. The Werkzeug documentation says it in plain words: "the debugger must never be used on production machines."&lt;/p&gt;

&lt;p&gt;This is not a theoretical risk. In 2015, Patreon, a company with millions of users, left the Werkzeug debugger publicly accessible. Security researchers at Detectify warned them about it, and days later attackers walked in through exactly that hole and dumped 15 gigabytes of user data online, including 2.3 million email addresses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to check:&lt;/strong&gt; search your whole codebase for "debug". Every match should end up controlled by the environment, never hardcoded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;debug&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FLASK_DEBUG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in production, don't use Flask's built-in server at all. Use Gunicorn:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gunicorn -w 4 -b 0.0.0.0:8000 "app:create_app()"&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Secrets sitting directly in the code
&lt;/h2&gt;

&lt;p&gt;Generated code loves this pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;secret_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;super-secret-key-123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;STRIPE_API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sk_live_...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it's dangerous:&lt;/strong&gt; anything committed to a Git repository should be treated as public. Yes, even in a private repo, because repos get shared, cloned, screenshotted, and pushed by accident. GitGuardian scans every public commit on GitHub, and their State of Secrets Sprawl 2026 report found 28.65 million new hardcoded secrets pushed to public GitHub in 2025 alone, up 34% year over year. Worse for us specifically: AI-assisted commits leaked secrets at roughly double the baseline rate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to check:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;grep -rInE "(secret|api[_-]?key|password|token)\s*=\s*[\"']" . --include="*.py"&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;If that command prints anything that looks like a real value, that is your to-do list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; secrets come from the environment, never from source code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;secret_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SECRET_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;secret_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SECRET_KEY is not set. Refusing to start insecurely.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generate a proper key:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;python -c "import secrets; print(secrets.token_hex(32))"&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Then set it wherever you deploy. Render, Railway, and Docker all have an environment variables section for exactly this. And if a secret already leaked: rotate it. Deleting the commit does nothing. The value already lives in the repo history and in every scanner's database.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. SQL injection hiding in "dynamic" queries
&lt;/h2&gt;

&lt;p&gt;Generators usually do the right thing and use an ORM like SQLAlchemy. The trouble starts when the AI needs something "dynamic," like a search filter, and reaches for string formatting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM users WHERE email = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it's dangerous:&lt;/strong&gt; that f-string is a door directly into your database. A user who types ' OR '1'='1' -- as their email just bypassed your login. Injection has been in the top three of the OWASP Top 10 for over two decades, precisely because one formatted string is all it takes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to check:&lt;/strong&gt; search for f", %, or .format( on the same line as execute(. Any hit is a red flag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; pass values as parameters and let the driver escape them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;

&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM users WHERE email = :email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or stay fully in the ORM, which is even simpler:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;user = User.query.filter_by(email=email).first()&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Rule of thumb: if you ever see an f-string inside a database query in AI-generated code, review the entire file. Where there is one, there are usually more.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Login endpoints anyone can hammer forever
&lt;/h2&gt;

&lt;p&gt;A typical generated login route looks fine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;methods&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;check_password&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;password_hash&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;create_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid credentials&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem is what it doesn't do: it lets anyone try passwords as fast as their machine can send requests. No limit, no slowdown, no lockout. Weak passwords fall in hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to check:&lt;/strong&gt; look at your login endpoint and ask a simple question. What stops someone from sending ten thousand requests? If the answer is "nothing," that is the hole.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; rate limit the sensitive endpoints. Flask-Limiter makes it a two-line change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask_limiter&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Limiter&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask_limiter.util&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_remote_address&lt;/span&gt;

&lt;span class="n"&gt;limiter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Limiter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_remote_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;storage_uri&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redis://localhost:6379&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;methods&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nd"&gt;@limiter.limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5 per minute&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five attempts per minute per IP turns "try a million passwords tonight" into "try a few thousand over days." Most attackers just move on. Do the same for registration, password reset, and any endpoint that sends emails or SMS. Those are abuse magnets too. And make sure passwords are stored with bcrypt or argon2, via Werkzeug's generate_password_hash, never plain text or fast hashes like MD5.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. No security headers at all
&lt;/h2&gt;

&lt;p&gt;Open your browser's developer tools, go to the Network tab, and click any response from your app. If you don't see headers like Content-Security-Policy or Strict-Transport-Security, your app is telling browsers "trust everything, embed me anywhere."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it's dangerous:&lt;/strong&gt; without a Content-Security-Policy, one reflected XSS, meaning a malicious script sneaked in through user input or a crafted link, runs with full access to your users' sessions. Without X-Frame-Options, an attacker can embed your app inside their own page and trick users into clicking things they can't see. That is clickjacking. Flask deliberately does not set these headers for you, and generators quietly skip the step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; one extension, about ten lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask_talisman&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Talisman&lt;/span&gt;

&lt;span class="nc"&gt;Talisman&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;content_security_policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default-src&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;self&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;script-src&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;self&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;style-src&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;self&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;force_https&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# let your reverse proxy (Nginx/Cloudflare) handle HTTPS
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your app legitimately needs external scripts like analytics or payment widgets, add those specific domains to the policy instead of disabling it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bigger picture
&lt;/h2&gt;

&lt;p&gt;None of this is really the AI's fault. Generators optimize for "does it run and does it look right." Security is invisible until the day it fails, so it gets skipped by default. Every generated app needs one deliberate hardening pass before real users and real data touch it. One hour, one checklist:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;No debug=True anywhere near production&lt;/li&gt;
&lt;li&gt;No secrets in code. Environment variables only, and rotate anything that ever leaked&lt;/li&gt;
&lt;li&gt;No f-strings or string formatting inside database queries&lt;/li&gt;
&lt;li&gt;Rate limits on login, registration, and reset endpoints&lt;/li&gt;
&lt;li&gt;Security headers via Flask-Talisman&lt;/li&gt;
&lt;li&gt;Bonus: passwords hashed with bcrypt or argon2, and dependencies scanned regularly&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;My rule of thumb, and the mindset behind everything we ship at Zyntari: treat AI-generated code like code from a fast, talented junior developer who has never been on call during a breach. A great starting point, never the finished product. Review the auth, review the queries, review the secrets. Then ship.&lt;/p&gt;




&lt;p&gt;Alon Trifonov is the founder of Zyntari (zyntari.com), an independent engineering studio in Israel that turns prototypes and AI-generated apps into secure, production-grade software. He builds with Python, Flask, and React, with security as a default rather than an afterthought &lt;a href="https://www.linkedin.com/in/alon-trifonov-546061234/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>flask</category>
      <category>python</category>
      <category>security</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
