<?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: Stack &amp; Ship</title>
    <description>The latest articles on DEV Community by Stack &amp; Ship (@edtrackai).</description>
    <link>https://dev.to/edtrackai</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%2F4183380%2F14460caf-4641-4775-a2a0-141388313df0.png</url>
      <title>DEV Community: Stack &amp; Ship</title>
      <link>https://dev.to/edtrackai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/edtrackai"/>
    <language>en</language>
    <item>
      <title>Cursor usage limits: the weekly pool, legacy plans and when they reset</title>
      <dc:creator>Stack &amp; Ship</dc:creator>
      <pubDate>Mon, 02 Nov 2026 13:55:35 +0000</pubDate>
      <link>https://dev.to/edtrackai/cursor-usage-limits-the-weekly-pool-legacy-plans-and-when-they-reset-31pf</link>
      <guid>https://dev.to/edtrackai/cursor-usage-limits-the-weekly-pool-legacy-plans-and-when-they-reset-31pf</guid>
      <description>&lt;p&gt;You were working in Cursor and got a notification that you've hit your usage limit. The answer to "when does it reset?" now depends on which kind of Cursor plan you have. Cursor's current docs describe two: plans on The X Subscription, which share one weekly pool, and legacy Cursor plans (Pro, Pro+, Ultra and the rest), which keep two monthly pools. This post, first published on &lt;a href="https://edtrackai.github.io/blog/cursor-usage-limits-reset-weekly-pool" rel="noopener noreferrer"&gt;our site&lt;/a&gt;, sets out what each kind includes, where to check your usage, when it resets, what happens when you run out, and the few things you control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checked 17 Oct 2026 against Cursor's own help, docs and pricing pages&lt;/strong&gt; (listed in Sources). Plan details change, so check those pages before you rely on a number here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we did and didn't test.&lt;/strong&gt; We don't have a Cursor subscription, so every plan detail below is quoted from Cursor's docs, not tested. We haven't hit a limit, watched a reset or seen a bill. The only things we ran ourselves are local: rule file sizes, a token count with an OpenAI tokenizer used as a rough proxy (labelled where it appears), and a small rule-audit script with 14 unit tests, all run with &lt;code&gt;TZ=UTC&lt;/code&gt;. We aren't affiliated with or endorsed by Cursor or Anysphere, Inc. Cursor is a trademark of Anysphere, Inc.; other product and model names are trademarks of their owners, used here only to identify them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The X Subscription (Free, Plus, Premium, Super, Ultra):&lt;/strong&gt; one weekly pool. Cursor's usage page says "your included pool refreshes weekly", and the next reset date is under Usage in your grok.com dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legacy Cursor plans (Hobby, Start (India only), Pro, Pro+, Ultra, Teams Standard and Premium):&lt;/strong&gt; two monthly pools. "Legacy Cursor plans: usage resets monthly with your billing cycle." The reset date is on the Spending tab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Either way:&lt;/strong&gt; "Unused included usage does not roll over on either kind of plan."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When you run out:&lt;/strong&gt; on The X Subscription, wait for the reset, use prepaid credit or upgrade. On a legacy plan, turn on on-demand usage or upgrade.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two kinds of Cursor plan
&lt;/h2&gt;

&lt;p&gt;Cursor's pricing help page puts it this way: "New customers choose a plan on The X Subscription. If you subscribed to Cursor before it launched, you're on a legacy Cursor plan, and it keeps working as it is."&lt;/p&gt;

&lt;p&gt;This comparison is summarised from Cursor's legacy plans page:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Legacy Cursor plans&lt;/th&gt;
&lt;th&gt;The X Subscription&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Included usage&lt;/td&gt;
&lt;td&gt;Two pools that refill monthly, one for Cursor's own models and one for other models&lt;/td&gt;
&lt;td&gt;A single pool that refills weekly, for Cursor's models, shared with Grok&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Usage reset&lt;/td&gt;
&lt;td&gt;Each month, on your billing cycle&lt;/td&gt;
&lt;td&gt;Each week&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Third-party models&lt;/td&gt;
&lt;td&gt;Covered by the other-models pool on Pro, Pro+, Ultra and Teams&lt;/td&gt;
&lt;td&gt;Not covered by the pool; on Premium and above they're charged at API rates from prepaid credit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra usage&lt;/td&gt;
&lt;td&gt;On-demand usage, billed at the end of each month&lt;/td&gt;
&lt;td&gt;Prepaid credit top-ups, which can be set to top up automatically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spend controls&lt;/td&gt;
&lt;td&gt;A limit on on-demand spend&lt;/td&gt;
&lt;td&gt;A monthly cap on additional usage&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The weekly pool is shared beyond the editor. The usage page says it is "shared across Cursor, Grok Bot, and the Grok apps", so usage elsewhere on the same subscription draws from the same allowance.&lt;/p&gt;

&lt;p&gt;Switching is optional. The pricing help page says: "Switching is optional. Your legacy plan, price, and features continue unless you change or cancel." If you do switch, the legacy plans page says "Your usage moves from two monthly pools to one weekly pool for Cursor models, shared with Grok." It also says "Third-party models stop being included." Cursor calls the switch "optional and permanent", so read both pages before you move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which plan am I on, and where do I check usage?
&lt;/h2&gt;

&lt;p&gt;If you pay for Pro ($20/mo), Pro+ ($60/mo) or the older Ultra ($200/mo) and haven't switched, you're on a legacy plan. These are the legacy prices listed on Cursor's pricing help page. New Ultra on The X Subscription is also $200/mo, so go by where you manage billing, not the plan name. Billing for legacy plans lives at cursor.com/dashboard/billing; billing for The X Subscription lives at grok.com/dashboard/billing.&lt;/p&gt;

&lt;p&gt;Where to check usage, from Cursor's usage page:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The X Subscription:&lt;/strong&gt; "open Usage at grok.com/dashboard. It shows how much of your weekly pool you've used, the next reset date, and your prepaid credit. Your editor settings show the same remaining usage."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legacy Cursor plans:&lt;/strong&gt; "go to the Spending tab in your dashboard. It shows real-time usage for both pools, remaining allowance, and any on-demand charges."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Hit your usage limit? When it resets
&lt;/h2&gt;

&lt;p&gt;Cursor's usage page says that when you hit your limit, "You'll see a notification in the editor." When it comes back depends on the plan:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The X Subscription:&lt;/strong&gt; "your included pool refreshes weekly. The next reset date is shown under Usage at grok.com/dashboard." Prepaid credit is separate: "Prepaid credit doesn't reset with your weekly pool; each top-up stays available until used or until it expires (currently 365 days after purchase)."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legacy Cursor plans:&lt;/strong&gt; "usage resets monthly with your billing cycle. The reset date is shown on the Spending tab." On teams, "all members' usage resets at the same time based on the team billing cycle."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cursor's pricing page describes the weekly design like this: "Your allowance refreshes weekly, so one heavy day never wipes out your whole month." The flip side, which the docs don't spell out but which follows from the reset rules, is that a weekly pool can run out mid-week and a monthly pool can run out mid-month. Neither carries unused usage forward.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when you run out
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On The X Subscription&lt;/strong&gt;, the usage page lists three options: "keep working from your prepaid credit, wait for your weekly pool to reset, or upgrade to a higher plan." Top-ups are paid up front. The usage-based charges page says "You're charged when you buy credit or when an automatic top-up runs, not in arrears", and Auto Top-up is "off until you turn it on."&lt;/p&gt;

&lt;p&gt;To cap paid extra usage, set a monthly additional usage limit at grok.com/dashboard/billing. The spend limits page says it "counts paid additional usage you consume, not the prepaid credit you buy", and "When you reach the limit, additional usage pauses until the next month or until you raise it, even if credit remains."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On a legacy plan&lt;/strong&gt;, the usage page says: "enable on-demand usage (pay-as-you-go) or upgrade to a higher plan." On-demand usage is opt-in for individuals: "Individual plans (Pro, Pro+, Ultra): On-demand usage must be explicitly enabled in your settings." It's "billed monthly in arrears at the same per-model rates as included usage, with no change in quality or speed." On the legacy Hobby (free) and Start (India only) plans, on-demand usage is "Not available. AI features pause until the pool resets."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two points about spend limits:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;They are not instant. "Enforcement is not instant, so usage can briefly exceed your spend limit."&lt;/li&gt;
&lt;li&gt;On legacy plans, raising the limit mid-cycle can cost you. "Raising a limit mid-cycle can make previously credited overage billable." The overages page explains that a small overage before enforcement is held as "a temporary spend-limit credit", and "If you raise the limit in the same cycle, we may bill some or all of that credit, up to the new limit."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What draws from which pool
&lt;/h2&gt;

&lt;p&gt;The usage page says "On either kind of plan, your model selection affects how quickly your included usage is consumed." The models page adds: "Different models draw from the pool at different rates."&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Third-party models (Claude, GPT, Gemini and others).&lt;/strong&gt; On The X Subscription they're outside the pool; the models page says they're available "at the model's API list price from the first request, paid from your prepaid credit." Cursor's pricing page also says: "Third Party model usage in Cursor is not included in the monthly prices shown and must be purchased separately." On legacy plans they draw from the Other Models pool, "drawn down at each model's API price."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto.&lt;/strong&gt; On individual plans, Auto runs in Cost mode, and "Cost mode (classic Auto) bills as a Cursor-model request and draws from your included pool whatever model serves it."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subagents.&lt;/strong&gt; These can surprise you: "Subagents can also run a named third-party model while the picker still shows Auto, Grok, or Composer. Those requests bill as third-party usage at that model's list price."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Max Mode.&lt;/strong&gt; "Current plans don't include Max Mode. On legacy request-based plans, Max Mode is billed at the model's API rate plus 20%."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own API key.&lt;/strong&gt; On individual plans, "Your provider bills you directly for the model cost, and those requests don't draw from your included usage or prepaid credit."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're also paying for these models directly, in an app you build, our &lt;a href="https://edtrackai.github.io/tools/ai-api-cost-calculator" rel="noopener noreferrer"&gt;AI API cost calculator&lt;/a&gt; estimates a monthly API bill for that app. It doesn't model Cursor's pools.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you control: model choice, spend caps and always-applied rules
&lt;/h2&gt;

&lt;p&gt;Cursor doesn't publish a per-request usage figure we could test, so this section sticks to what the docs say and what we measured locally.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Model choice.&lt;/strong&gt; This is the lever Cursor names. On The X Subscription, the usage-based charges page suggests: "Use Cursor models: Grok and Composer draw from your included pool instead of prepaid credit."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spend caps.&lt;/strong&gt; Leave Auto Top-up off or set a monthly additional usage limit (The X Subscription), or keep on-demand off or set a spend limit (legacy). Both are covered above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rules that load in every chat.&lt;/strong&gt; Cursor's rules docs say "When applied, rule contents are included at the start of the model context", and "If alwaysApply is true, the rule will be applied to every chat session." Rules with &lt;code&gt;alwaysApply: false&lt;/code&gt; load only when a matching file is in context, when the Agent decides the description is relevant, or when you @-mention them. Cursor's own guidance is "Keep rules under 500 lines".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We don't know how much of your pool a given rule uses, and we haven't measured any usage saving from trimming rules. What we can say is that an always-applied rule puts its whole text into the context of every chat, so it's worth knowing which of your rules are always-applied and how long they are. This script lists them.&lt;/p&gt;

&lt;h3&gt;
  
  
  A rule-audit script
&lt;/h3&gt;

&lt;p&gt;Save it as &lt;code&gt;audit-rules.ts&lt;/code&gt; and run it from your project root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx tsx audit-rules.ts .cursor/rules
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads each &lt;code&gt;.mdc&lt;/code&gt; file (including subfolders, and skipping plain &lt;code&gt;.md&lt;/code&gt; files, which Cursor's docs say the rules system ignores), sorts it into one of the four behaviours from Cursor's frontmatter table, and totals the always-applied rules.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// audit-rules.ts: list which Cursor rules load in every chat.&lt;/span&gt;
&lt;span class="c1"&gt;// Usage: npx tsx audit-rules.ts .cursor/rules&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;statSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;join&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;relative&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:path&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;always&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;file-match&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agent-decides&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;manual&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Kind&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;warnings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Line-based frontmatter read. Globs like **/*.sql are not valid YAML&lt;/span&gt;
&lt;span class="c1"&gt;// scalars (a leading * is an alias), so we don't use a YAML parser.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;warnings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^---&lt;/span&gt;&lt;span class="se"&gt;\r?\n([\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?)\r?\n&lt;/span&gt;&lt;span class="sr"&gt;---&lt;/span&gt;&lt;span class="se"&gt;\r?\n?&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;warnings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no frontmatter: treated as manual&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="k"&gt;of &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\r?\n&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;always&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alwaysApply&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;globs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globs&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;|&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;$/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;desc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;|&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;$/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alwaysApply&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;false&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alwaysApply&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nx"&gt;warnings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`alwaysApply is "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alwaysApply&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;", not true/false`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;always&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;globs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;warnings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;alwaysApply: true ignores globs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;always&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;always&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;globs&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;file-match&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;desc&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agent-decides&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;manual&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&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="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\r?\n&lt;/span&gt;&lt;span class="sr"&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="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;warnings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;over 500 lines&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;warnings&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;listRules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&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;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;statSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isDirectory&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;listRules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.mdc&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Rule&lt;/span&gt;&lt;span class="p"&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;listRules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;parseRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;relative&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;audit-rules.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.cursor/rules&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;13&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="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&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="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;warnings&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rules&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="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;always&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`every chat: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;on&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; rule(s), `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;on&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lines&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="s2"&gt; lines, `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;on&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chars&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="s2"&gt; chars`&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;How it decides, following Cursor's frontmatter table:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;alwaysApply: true&lt;/code&gt; is "always". Cursor says globs and description are then ignored, so the script warns if globs are set.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;alwaysApply: false&lt;/code&gt; with globs is "file-match".&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;alwaysApply: false&lt;/code&gt; with a description and no globs is "agent-decides".&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;alwaysApply: false&lt;/code&gt; with neither is "manual" (only when you @-mention it).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It reads the frontmatter line by line instead of with a YAML parser, because a glob such as &lt;code&gt;**/*.sql&lt;/code&gt; starts with &lt;code&gt;*&lt;/code&gt;, which YAML treats as an alias. Lines count the whole file; characters count the rule text after the frontmatter. It type-checks under &lt;code&gt;tsc --strict&lt;/code&gt; and passes 14 unit tests (each rule kind, quoted values, a missing frontmatter block, CRLF line endings, a non-boolean &lt;code&gt;alwaysApply&lt;/code&gt;, the 500-line boundary, nested folders and a skipped &lt;code&gt;.md&lt;/code&gt; file).&lt;/p&gt;

&lt;p&gt;On a small demo folder with one rule of each kind, it printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;file-match        7      70  frontend/components.mdc
agent-decides     5      45  migrations.mdc
always            6      88  project.mdc
manual            4      53  release.mdc
every chat: 1 rule(s), 6 lines, 88 chars
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What it shows on a real rule set
&lt;/h3&gt;

&lt;p&gt;We ran it on the six Cursor rules in our own AI Rules v2 pack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;always           12     647  00-project.mdc
file-match       17    1260  10-nextjs.mdc
file-match       31    2679  20-supabase.mdc
file-match       21    2054  30-stripe.mdc
always           10     437  40-security.mdc
file-match        8     293  50-testing.mdc
every chat: 2 rule(s), 22 lines, 1084 chars
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two short rules (22 lines, 1,084 characters of rule text) are always applied. The other four (77 lines, 6,286 characters) load only when a matching file is in context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rough tokenizer proxy (labelled).&lt;/strong&gt; We also counted each whole file with OpenAI's &lt;code&gt;o200k_base&lt;/code&gt; tokenizer (js-tiktoken 1.0.21): 297 tokens for the two always-applied rules and 1,671 for the other four. Treat this as a rough size comparison only. Cursor's models may count tokens differently, and we have no way to see how Cursor charges a rule against your pool.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Rate limited by model provider" is a different message
&lt;/h2&gt;

&lt;p&gt;Searches for Cursor limits often include "rate limited by model provider". That message isn't covered on the Cursor usage and pricing pages we read, so we won't guess how Cursor handles it, or say whether it touches your plan's pool. If you call these model APIs yourself, our guide to &lt;a href="https://edtrackai.github.io/blog/openai-anthropic-429-rate-limit-error-nextjs" rel="noopener noreferrer"&gt;OpenAI and Anthropic 429 errors&lt;/a&gt; explains provider rate limits and which errors to retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you also use Codex or Claude Code
&lt;/h2&gt;

&lt;p&gt;Each tool measures its limits differently, so a side-by-side comparison would mostly compare plan tables that keep changing. For the other two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Codex:&lt;/strong&gt; &lt;a href="https://edtrackai.github.io/blog/chatgpt-pro-200-codex-usage-limits" rel="noopener noreferrer"&gt;ChatGPT Pro and Codex usage limits&lt;/a&gt; covers Codex on ChatGPT plans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code:&lt;/strong&gt; &lt;a href="https://edtrackai.github.io/blog/claude-code-reduce-token-usage-limits" rel="noopener noreferrer"&gt;Claude Code: reduce token usage and make limits last&lt;/a&gt; covers Claude Code on Pro and Max.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://edtrackai.github.io/blog/cursor-rules-nextjs-supabase-stripe" rel="noopener noreferrer"&gt;Cursor rules for Next.js, Supabase and Stripe&lt;/a&gt;: the security-focused rules themselves, and the mistake each one is designed to catch.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://edtrackai.github.io/tools/ai-api-cost-calculator" rel="noopener noreferrer"&gt;AI API cost calculator&lt;/a&gt;: estimate a monthly API bill for an app you build.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://edtrackai.github.io/blog/claude-code-reduce-token-usage-limits" rel="noopener noreferrer"&gt;Claude Code: reduce token usage and make limits last&lt;/a&gt;: the same question for Claude Code.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Disclosure: this is our own paid product.&lt;/em&gt; &lt;a href="https://edtrackai.github.io/ai-rules?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=e51" rel="noopener noreferrer"&gt;AI Rules v2&lt;/a&gt; is a Claude Code plugin with guardrail hooks, skills and subagents for Next.js, Supabase and Stripe. It also includes the six Cursor rules measured above: two short always-applied rules and four scoped to matching files. It doesn't reduce or raise your Cursor usage limits, measure your usage or change your billing, and we haven't measured any usage saving from it. The hooks are Claude Code specific; in Cursor you get the rules. It costs $19 (plus VAT where applicable).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources (checked 17 October 2026):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://cursor.com/help/models-and-usage/usage-limits" rel="noopener noreferrer"&gt;Cursor Help: Usage and limits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cursor.com/help/account-and-billing/pricing" rel="noopener noreferrer"&gt;Cursor Help: Pricing and plans&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cursor.com/docs/models-and-pricing" rel="noopener noreferrer"&gt;Cursor Docs: Models &amp;amp; Pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cursor.com/docs/account/pricing/legacy-cursor-plans" rel="noopener noreferrer"&gt;Cursor Docs: Legacy Cursor plans&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cursor.com/help/account-and-billing/overages" rel="noopener noreferrer"&gt;Cursor Help: Usage-based charges&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cursor.com/help/account-and-billing/spend-limits" rel="noopener noreferrer"&gt;Cursor Help: Spend limits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cursor.com/docs/rules" rel="noopener noreferrer"&gt;Cursor Docs: Rules&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cursor.com/pricing" rel="noopener noreferrer"&gt;Cursor: Pricing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cursor</category>
      <category>ai</category>
      <category>productivity</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Stripe Webhook Signature Verification Failed in Next.js</title>
      <dc:creator>Stack &amp; Ship</dc:creator>
      <pubDate>Sat, 31 Oct 2026 13:51:24 +0000</pubDate>
      <link>https://dev.to/edtrackai/stripe-webhook-signature-verification-failed-in-nextjs-1gic</link>
      <guid>https://dev.to/edtrackai/stripe-webhook-signature-verification-failed-in-nextjs-1gic</guid>
      <description>&lt;p&gt;Your checkout works, the payment shows up in Stripe, and then your webhook route throws this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Webhook signature verification failed. Err: No signatures found matching the expected signature for payload.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user paid, but your app never finds out. Stripe's own troubleshooting page puts it simply: if you see this error, at least one of the three values you passed to &lt;code&gt;constructEvent()&lt;/code&gt; is wrong. This guide, first published on &lt;a href="https://edtrackai.github.io/blog/stripe-webhook-signature-verification-failed-nextjs?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=stripe-webhook" rel="noopener noreferrer"&gt;our site&lt;/a&gt;, walks through each cause in a Next.js App Router project, in the order you're most likely to hit them, and ends with a working handler and a debug checklist. The Stripe and Next.js behaviour described here comes from their official docs, checked on 14 October 2026 (sources at the end). We aren't affiliated with Stripe.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the error means
&lt;/h2&gt;

&lt;p&gt;Stripe signs every webhook it sends and puts the signature in the &lt;code&gt;Stripe-Signature&lt;/code&gt; header. The header looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;t=1729000000,v1=5257a869e7ec...,v0=...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;t&lt;/code&gt; is a timestamp and &lt;code&gt;v1&lt;/code&gt; is an HMAC-SHA256 signature of the timestamp joined to the exact request body, made with your endpoint's signing secret (it starts with &lt;code&gt;whsec_&lt;/code&gt;). When you call &lt;code&gt;constructEvent()&lt;/code&gt; with the body, the signature and the secret, the library recomputes that signature and compares. It needs three things to be exactly right:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The raw body&lt;/strong&gt;: the exact UTF-8 string Stripe sent, byte for byte.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The signature&lt;/strong&gt;: the &lt;code&gt;Stripe-Signature&lt;/code&gt; header value, unchanged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The secret&lt;/strong&gt;: the &lt;code&gt;whsec_&lt;/code&gt; secret for &lt;em&gt;this&lt;/em&gt; endpoint, in &lt;em&gt;this&lt;/em&gt; mode.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There are two error messages you'll usually see. "No signatures found matching the expected signature for payload" means the header was parsed but nothing matched, so the body or the secret is wrong. "Unable to extract timestamp and signatures from header" means the header itself is missing or malformed.&lt;/p&gt;

&lt;p&gt;This check is the only thing proving the request came from Stripe. Treat a failure as a bug in your setup, not an annoyance to switch off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 1: the body was parsed before verification
&lt;/h2&gt;

&lt;p&gt;This is a common bug in code that AI agents write. In an App Router route handler, the body is a stream you can read once. If you call &lt;code&gt;req.json()&lt;/code&gt; and then &lt;code&gt;JSON.stringify()&lt;/code&gt; the result to verify it, the keys, whitespace or encoding can change, and the signature no longer matches.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wrong: the body has been parsed and re-serialised&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Right: verify the exact text Stripe sent&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Next.js docs show &lt;code&gt;request.text()&lt;/code&gt; for webhooks in route handlers and note that, unlike Pages Router API routes, you don't need any &lt;code&gt;bodyParser&lt;/code&gt; configuration. If you're still on the Pages Router, Stripe's docs say to disable &lt;code&gt;bodyParser&lt;/code&gt; for the route and read the raw buffer instead.&lt;/p&gt;

&lt;p&gt;Anything that rewrites the body before your handler sees it causes the same failure: a custom server using &lt;code&gt;express.json()&lt;/code&gt; before the webhook route, a proxy that re-encodes JSON, or a wrapper that "helpfully" parses every request. We cover this bug pattern, and why agents write it, in &lt;a href="https://edtrackai.github.io/blog/7-security-bugs-ai-agents-write-nextjs-supabase-stripe?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=stripe-webhook" rel="noopener noreferrer"&gt;7 security bugs AI agents write in Next.js, Supabase and Stripe&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 2: the wrong signing secret
&lt;/h2&gt;

&lt;p&gt;Stripe's troubleshooting page calls this the most common cause. Every endpoint has its own secret, and several situations hand you a different one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stripe CLI vs Dashboard.&lt;/strong&gt; &lt;code&gt;stripe listen&lt;/code&gt; prints its own &lt;code&gt;whsec_&lt;/code&gt; secret in the terminal. It's different from the secret of the endpoint you created in the Dashboard (Workbench, then Reveal secret). Using the CLI secret in production, or the Dashboard secret locally, fails every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test vs live mode.&lt;/strong&gt; Stripe's docs say that if you use the same endpoint for test and live keys, the secret is different for each.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple endpoints.&lt;/strong&gt; If you've registered more than one endpoint, each has its own secret. Copying the secret from endpoint A into the app behind endpoint B won't work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The env var isn't what you think.&lt;/strong&gt; It's missing in production, set only for preview deployments, has a trailing space or newline, or you rolled the secret and forgot to update it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stripe suggests printing the secret your code actually uses and comparing it with the source. Do that locally, or log only the first few characters in production; never log the full secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 3: the header or the runtime
&lt;/h2&gt;

&lt;p&gt;If you get "Unable to extract timestamp and signatures from header", check how you read the header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Missing signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Header names are case-insensitive with &lt;code&gt;headers.get()&lt;/code&gt;, but a proxy or tunnel that strips custom headers will cause this error too. Print the value and confirm it looks like &lt;code&gt;t=...,v1=...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The runtime matters as well. The synchronous &lt;code&gt;constructEvent()&lt;/code&gt; uses Node's crypto. On the Edge runtime, stripe-node needs &lt;code&gt;constructEventAsync()&lt;/code&gt; with a Web Crypto provider (&lt;code&gt;Stripe.createSubtleCryptoProvider()&lt;/code&gt;); if you call the sync version there, the library tells you to use the async one. The simplest fix is to pin the route to Node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nodejs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Cause 4: timestamp tolerance and clock skew
&lt;/h2&gt;

&lt;p&gt;Stripe includes the timestamp in the signed payload to protect against replay attacks. Stripe's docs say its libraries reject events whose timestamp is more than &lt;strong&gt;5 minutes&lt;/strong&gt; from the server's current time by default. You'll hit this if your server clock drifts, or if you replay an old saved request with its old header while testing.&lt;/p&gt;

&lt;p&gt;The fix is to keep your server's clock in sync (Stripe recommends NTP), not to widen the tolerance. Stripe specifically warns against a tolerance of &lt;code&gt;0&lt;/code&gt;, which turns the recency check off entirely. To test an old event, resend it from the Dashboard or trigger a fresh one with &lt;code&gt;stripe trigger&lt;/code&gt;, so it gets a new signature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never "fix" it by skipping verification
&lt;/h2&gt;

&lt;p&gt;When a deadline is close, the tempting fix is to delete the &lt;code&gt;constructEvent&lt;/code&gt; call and &lt;code&gt;JSON.parse&lt;/code&gt; the body. Don't. Stripe's docs spell out what happens: without verification, anyone can send fake events to your endpoint to fulfil orders, grant access or change records. Your webhook URL isn't secret, so a forged &lt;code&gt;checkout.session.completed&lt;/code&gt; would give anyone your product for free.&lt;/p&gt;

&lt;p&gt;On failure, return a &lt;code&gt;400&lt;/code&gt;, log &lt;em&gt;why&lt;/em&gt; it failed (the error message, whether the header was present, which secret variable was loaded), and don't log the full payload or secret. If you want a second layer, Stripe also publishes the IP addresses webhooks come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  A working Next.js App Router handler
&lt;/h2&gt;

&lt;p&gt;This is a minimal handler: Node runtime, raw text body, verification first, and a fast &lt;code&gt;2xx&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/stripe/webhook/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nodejs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;endpointSecret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Missing signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&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="c1"&gt;// raw body, read once&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endpointSecret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Stripe signature check failed:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Dedupe on event.id before granting anything: Stripe can deliver&lt;/span&gt;
  &lt;span class="c1"&gt;// the same event more than once.&lt;/span&gt;
  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;checkout.session.completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;// look up the user from data you set server-side, then grant access&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&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;Two things this skeleton leaves to you. First, idempotency: Stripe's docs say endpoints can receive the same event more than once, so record processed event ids and skip repeats (the same 7-bugs post shows a pattern for that). Second, speed: Stripe asks you to return &lt;code&gt;2xx&lt;/code&gt; quickly and move slow work to a queue, or deliveries can time out and be retried.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debug checklist
&lt;/h2&gt;

&lt;p&gt;Run these in order. Most people find the problem in the first three.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which secret is loaded? Compare the first characters of &lt;code&gt;STRIPE_WEBHOOK_SECRET&lt;/code&gt; in the running environment with the source: the &lt;code&gt;stripe listen&lt;/code&gt; output locally, or the Dashboard endpoint in production.&lt;/li&gt;
&lt;li&gt;Is it the right mode? Test-mode events need the test secret; live events need the live secret.&lt;/li&gt;
&lt;li&gt;Is it the right endpoint? Check the delivery's endpoint URL in Workbench matches the app you're debugging.&lt;/li&gt;
&lt;li&gt;Is the body raw? Search the route for &lt;code&gt;req.json()&lt;/code&gt;, &lt;code&gt;JSON.stringify&lt;/code&gt; or any middleware that touches the body.&lt;/li&gt;
&lt;li&gt;Is the header there? Log whether &lt;code&gt;stripe-signature&lt;/code&gt; is present and starts with &lt;code&gt;t=&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Which runtime? Confirm &lt;code&gt;export const runtime = "nodejs"&lt;/code&gt;, or use &lt;code&gt;constructEventAsync&lt;/code&gt; on Edge.&lt;/li&gt;
&lt;li&gt;Is the clock right? Compare the server time with the &lt;code&gt;t=&lt;/code&gt; value.&lt;/li&gt;
&lt;li&gt;Is anything in between? Proxies, tunnels or redirects (Stripe treats a &lt;code&gt;3xx&lt;/code&gt; as a failed delivery).&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why does it work with &lt;code&gt;stripe listen&lt;/code&gt; but not in production?
&lt;/h3&gt;

&lt;p&gt;Almost always the secret. The CLI prints its own secret, which only works for events it forwards. Production needs the secret of the Dashboard endpoint, set in your production environment variables.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I verify Stripe webhooks on the Edge runtime?
&lt;/h3&gt;

&lt;p&gt;Yes, with &lt;code&gt;constructEventAsync()&lt;/code&gt; and &lt;code&gt;Stripe.createSubtleCryptoProvider()&lt;/code&gt;. Pinning the route to the Node runtime is simpler and avoids the problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a different secret per environment?
&lt;/h3&gt;

&lt;p&gt;Each endpoint and mode has its own secret, so production, staging and local CLI forwarding will each have one. Store them per environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it safe to log the raw body?
&lt;/h3&gt;

&lt;p&gt;Avoid it in production. Events can contain customer names, emails and addresses. Log the event id, type and the verification error instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the rest of your Stripe setup
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Disclosure: Launch Audit and AI Rules are our own paid products.&lt;/em&gt; &lt;a href="https://edtrackai.github.io/launch-audit?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=stripe-webhook" rel="noopener noreferrer"&gt;Launch Audit&lt;/a&gt; ($12 plus VAT where applicable) runs 28 checks across a Next.js, Supabase and Stripe codebase. Its Stripe checks include a webhook with no &lt;code&gt;constructEvent&lt;/code&gt; call, webhooks with no event-id idempotency, and a webhook route on the Edge runtime. It also flags webhook secrets exposed through &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; variables, and its manual checklist reminds you to keep your live webhook endpoint and secret separate from test. &lt;a href="https://edtrackai.github.io/ai-rules?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=stripe-webhook" rel="noopener noreferrer"&gt;AI Rules&lt;/a&gt; ($19 plus VAT where applicable) gives your coding agent rules for this stack, including one that tells it to read the raw body before verifying Stripe webhooks. They reduce risk; they don't replace code review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch this in CI.&lt;/strong&gt; Add our free checker (ours, MIT) to your PR workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;edtrackai/nextjs-supabase-stripe-security-checker@v0&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;fail-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;high&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs 24 checks on every pull request and keeps one PR comment with the findings. Full workflow: &lt;a href="https://github.com/edtrackai/nextjs-supabase-stripe-security-checker/blob/main/docs/github-action.md" rel="noopener noreferrer"&gt;docs/github-action.md&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We aren't affiliated with or endorsed by Stripe, Supabase or Vercel; Stripe, Supabase and Next.js are their owners' trademarks, and other product names belong to their owners; all are used here only to name those products.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources (checked 14 October 2026):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.stripe.com/webhooks/signature" rel="noopener noreferrer"&gt;Stripe: Resolve webhook signature verification errors&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;Stripe: Receive Stripe events in your webhook endpoint&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nextjs.org/docs/app/api-reference/file-conventions/route" rel="noopener noreferrer"&gt;Next.js: route.js (Route Handlers)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/stripe/stripe-node/blob/master/src/Webhooks.ts" rel="noopener noreferrer"&gt;stripe-node: Webhooks.ts&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;More fixes on the &lt;a href="https://edtrackai.github.io/blog/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=footer" rel="noopener noreferrer"&gt;Stack &amp;amp; Ship blog&lt;/a&gt;. Estimate your AI API bill with our &lt;a href="https://edtrackai.github.io/tools/ai-api-cost-calculator?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=footer" rel="noopener noreferrer"&gt;free AI API cost calculator&lt;/a&gt; (estimates only).&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>nextjs</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Claude 5-hour limit draining fast in T3 Code or the Agent SDK</title>
      <dc:creator>Stack &amp; Ship</dc:creator>
      <pubDate>Sat, 31 Oct 2026 06:40:40 +0000</pubDate>
      <link>https://dev.to/edtrackai/claude-5-hour-limit-draining-fast-in-t3-code-or-the-agent-sdk-2ob9</link>
      <guid>https://dev.to/edtrackai/claude-5-hour-limit-draining-fast-in-t3-code-or-the-agent-sdk-2ob9</guid>
      <description>&lt;p&gt;Your Claude 5-hour limit runs out far sooner than usual when you work in T3 Code, an app built on the Claude Agent SDK, or headless &lt;code&gt;claude -p&lt;/code&gt;, even though the same kind of work in the Claude Code terminal app lasts much longer.&lt;/p&gt;

&lt;p&gt;T3 Code's maker, Theo, posted: "I confirmed it's a real bug on Anthropic's side." That confirmation comes from Theo's own testing, not from Anthropic. As of 31 Oct 2026, Anthropic hasn't confirmed it or announced a fix. On 28 Oct, Boris Cherny of Anthropic &lt;a href="https://x.com/bcherny/status/2115592371224346734" rel="noopener noreferrer"&gt;posted on X&lt;/a&gt; a general review of Claude Code usage complaints: no single root cause or regression, a few smaller token-burn issues being fixed, and most fast drains traced to one or two heavy sessions, such as xhigh effort, /goal, Workflows, Agent Teams or long SDK jobs. It doesn't mention this report or the headless-versus-interactive gap, so we can't read it as an answer to it.&lt;/p&gt;

&lt;p&gt;What you can do while the reports are open:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check your real usage.&lt;/strong&gt; Run &lt;code&gt;/usage&lt;/code&gt; in Claude Code before you change anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move heavy work to the interactive app.&lt;/strong&gt; It's the path users measured as cheaper on the 5-hour window. This is a workaround based on user tests, not an official recommendation. On Max, Ultra and Team, Anthropic's monthly API credits, once you've claimed them, cover Agent SDK and &lt;code&gt;claude -p&lt;/code&gt; runs that use an API key from your linked Console organization (see Anthropic's help article for amounts and limits); some commenters on #97074 report an environment-variable workaround, others warn it may breach Anthropic's terms, and we don't recommend it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't confuse it with a separate report.&lt;/strong&gt; anthropics/claude-code#104738 is a different symptom: two surfaces disagreeing about the limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know your account type.&lt;/strong&gt; On 30 Oct, Anthropic &lt;a href="https://x.com/claudeai/status/2116220869479428500" rel="noopener noreferrer"&gt;said on X&lt;/a&gt; it is removing the 5-hour limit for verified Ultra accounts. This report is about the plans and accounts that still have a 5-hour limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add your data to the issue.&lt;/strong&gt; Post your plan, model, Claude Code version and before-and-after 5-hour percentages on &lt;a href="https://github.com/anthropics/claude-code/issues/97074" rel="noopener noreferrer"&gt;anthropics/claude-code#97074&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The full guide has the user-reported numbers, sources and the status-line fields for watching your limit:&lt;br&gt;
&lt;a href="https://edtrackai.github.io/blog/claude-5-hour-limit-draining-fast-agent-sdk?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=rel46c-5h-limit" rel="noopener noreferrer"&gt;Claude 5 hour limit draining fast in T3 Code or Agent SDK&lt;/a&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>productivity</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Claude Code usage limits: how to reduce token usage so they last (Pro and Max)</title>
      <dc:creator>Stack &amp; Ship</dc:creator>
      <pubDate>Fri, 30 Oct 2026 13:53:54 +0000</pubDate>
      <link>https://dev.to/edtrackai/claude-code-usage-limits-how-to-reduce-token-usage-so-they-last-pro-and-max-2mnm</link>
      <guid>https://dev.to/edtrackai/claude-code-usage-limits-how-to-reduce-token-usage-so-they-last-pro-and-max-2mnm</guid>
      <description>&lt;p&gt;If Claude Code says you've hit your limit before the day is out, the cause is often how much context each request carries, not how many prompts you typed. This post, first published on &lt;a href="https://edtrackai.github.io/blog/claude-code-reduce-token-usage-limits" rel="noopener noreferrer"&gt;our site&lt;/a&gt;, explains what draws on your Pro or Max limits, how to see it before you run out, and the settings and habits that can help reduce Claude Code token usage. It ends with a status line, a hook and a subagent file you can copy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checked 17 Oct 2026 on Claude Code 2.1.302.&lt;/strong&gt; Everything about limits and commands comes from Anthropic's own docs and help articles, linked in Sources. Claude Code changes most weeks, so check &lt;code&gt;claude --version&lt;/code&gt; against the version above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we did and didn't test.&lt;/strong&gt; We ran no Claude subscription for this post, so we measured no real plan usage and can't say how much any tip saves. We tested the config offline: the status line script against sample input in the documented format, the hook against sample test output, the subagent file with &lt;code&gt;claude plugin validate&lt;/code&gt;, and the settings files as valid JSON. Without signing in we couldn't run slash commands, so every one named here was checked against the built-in command list in Claude Code 2.1.302, and what they do comes from Anthropic's docs. We aren't affiliated with or endorsed by Anthropic; Claude and Claude Code are its trademarks, used here only to identify them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Claude Code usage limits work on Pro and Max
&lt;/h2&gt;

&lt;p&gt;Anthropic's help centre describes two limits on paid plans:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A five-hour session limit.&lt;/strong&gt; It resets every five hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A weekly limit.&lt;/strong&gt; It resets once a week. Max plans also have a separate weekly limit for Fable models.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These limits are shared: what you do in Claude on the web, desktop or mobile and in Claude Code all counts against the same limits. Anthropic doesn't publish the limits as token numbers, so watch the percentage bars in Claude Code and under Settings &amp;gt; Usage on claude.ai.&lt;/p&gt;

&lt;p&gt;Two recent official changes are worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Weekly limits.&lt;/strong&gt; A promotion raised Claude Code weekly limits by 50% from 13 May to 13 September 2026. From 14 September, Anthropic says weekly limits in Claude Code are 25% higher than they were before the promotion, on Pro, Max, Team and seat-based Enterprise plans. The promotion didn't change five-hour limits. If your weekly bar fills sooner than it did in August, the end of that promotion is the documented change; we found no official cut to the limits in October.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrap-up allowance.&lt;/strong&gt; If you reach the five-hour limit partway through a response, Claude Code may keep working briefly to finish the step in progress. You'll see "Usage limit reached · wrapping up". It's small and capped, counts toward your weekly limit, needs Claude Code 2.1.277 or later, and is rolling out gradually. On Pro it applies up to once per weekly period.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The session and weekly limits are shared across all models, so switching models with &lt;code&gt;/model&lt;/code&gt; won't restore access once you've hit one. A model-specific message, such as "You've hit your Opus limit", is different: switching to another model family does let you keep working.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually uses your limit
&lt;/h2&gt;

&lt;p&gt;Claude Code sends your whole conversation with every request. Each time Claude uses a tool, another request goes out carrying that batch of tool results. So a one-line question in a session that's been open all day still draws on your limit for the whole conversation, although prompt caching means the repeated history is billed at the cached rate.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;Anthropic's cost docs&lt;/a&gt; list the usual reasons usage climbs in a long session:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Long context.&lt;/strong&gt; Files Claude has read, pasted logs and tool output all stay in the conversation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache misses.&lt;/strong&gt; Your first message after a break longer than the cache lifetime reprocesses the full context. On a subscription the lifetime is an hour. It drops to five minutes once you're drawing on usage credits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subagents and agent teams.&lt;/strong&gt; Each one sends its own requests on top of the main conversation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled tasks.&lt;/strong&gt; A scheduled task fires on its interval even while you're away, sending the full context each time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compaction.&lt;/strong&gt; &lt;code&gt;/compact&lt;/code&gt; reads the conversation it summarizes, so compacting a large context is itself a large request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Effort level and model choice matter too. The help centre lists both as factors in how fast you use your limit, and says higher effort uses more tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  See your usage before you hit the wall
&lt;/h2&gt;

&lt;p&gt;In Claude Code 2.1.302, &lt;code&gt;/usage&lt;/code&gt; is the command to use. Its built-in description reads "Show session cost, plan usage, and activity stats", and &lt;code&gt;/cost&lt;/code&gt; and &lt;code&gt;/stats&lt;/code&gt; are aliases for it. Some help articles still point to &lt;code&gt;/status&lt;/code&gt; for your allocation, but in 2.1.302 its description is "Show Claude Code status including version, model, account, API connectivity, and tool statuses". On a Pro or Max plan, &lt;code&gt;/usage&lt;/code&gt; shows your plan usage bars plus a breakdown of recent usage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Attribution.&lt;/strong&gt; The share used by skills, subagents, plugins and individual MCP servers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Behaviour flags.&lt;/strong&gt; Things like long context or cache misses, flagged when one accounts for 10% or more of recent usage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loops.&lt;/strong&gt; The heaviest scheduled tasks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Press &lt;code&gt;d&lt;/code&gt; or &lt;code&gt;w&lt;/code&gt; to switch between the last 24 hours and the last 7 days. The breakdown comes from local session history on that machine, so it doesn't include claude.ai or other devices. The dollar figure in the Session block is meant for API users; on a subscription it isn't what you're billed.&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;/context&lt;/code&gt; to see what's filling the context window right now.&lt;/p&gt;

&lt;h3&gt;
  
  
  A status line that shows your 5-hour and weekly use
&lt;/h3&gt;

&lt;p&gt;The status line gets a JSON object on stdin. For Pro and Max subscribers it includes a &lt;code&gt;rate_limits&lt;/code&gt; object with a &lt;code&gt;five_hour&lt;/code&gt; and a &lt;code&gt;seven_day&lt;/code&gt; window, each with a used percentage from 0 to 100 and a reset time in Unix seconds. It only appears after the session's first response, and each window can be absent, so the script has to cope with missing fields.&lt;/p&gt;

&lt;p&gt;Save this as &lt;code&gt;~/.claude/statusline.sh&lt;/code&gt; and make it executable with &lt;code&gt;chmod +x&lt;/code&gt;. It needs &lt;code&gt;jq&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Status line for Claude Code: model, context %, 5-hour and 7-day plan usage.&lt;/span&gt;
&lt;span class="c"&gt;# rate_limits only appears for Pro/Max subscribers, after the first response.&lt;/span&gt;
&lt;span class="nv"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;MODEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.model.display_name // "?"'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;CTX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.context_window.used_percentage // 0'&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;FIVE_H&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.rate_limits.five_hour.used_percentage // empty'&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;WEEK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.rate_limits.seven_day.used_percentage // empty'&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;RESET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.rate_limits.five_hour.resets_at // empty'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;OUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="nv"&gt;$MODEL&lt;/span&gt;&lt;span class="s2"&gt;] ctx &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CTX&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FIVE_H&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;OUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OUT&lt;/span&gt;&lt;span class="s2"&gt; | 5h &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;FIVE_H&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RESET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;OUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OUT&lt;/span&gt;&lt;span class="s2"&gt; (resets &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"@&lt;/span&gt;&lt;span class="nv"&gt;$RESET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +%H:%M 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RESET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; +%H:%M&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WEEK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;OUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OUT&lt;/span&gt;&lt;span class="s2"&gt; | week &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;WEEK&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then point your settings at it in &lt;code&gt;~/.claude/settings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"statusLine"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"~/.claude/statusline.sh"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We fed it sample input built from the documented schema (5-hour window 23.5% used, 7-day window 41.2%). This is its real output on our box, run with &lt;code&gt;TZ=UTC&lt;/code&gt;; the reset time is shown in the machine's local time zone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Opus] ctx 8% | 5h 23% (resets 10:00) | week 41%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With an API key there's no &lt;code&gt;rate_limits&lt;/code&gt; object, so it prints only the model and context share. We tested both cases, plus a case with only the five-hour window present, using sample input. We haven't seen it run against a live subscription.&lt;/p&gt;

&lt;h2&gt;
  
  
  /clear vs /compact: when to use which
&lt;/h2&gt;

&lt;p&gt;Anthropic's rule of thumb is to use &lt;code&gt;/clear&lt;/code&gt; when starting a new task and &lt;code&gt;/compact&lt;/code&gt; when continuing a long one.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/clear&lt;/code&gt;&lt;/strong&gt; starts a new session with empty context. The cost docs say it costs nothing. Your CLAUDE.md and project files are still available, and the old session stays on disk, so you can get back to it with &lt;code&gt;/resume&lt;/code&gt;. Use &lt;code&gt;/rename&lt;/code&gt; first if you want to find it easily.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/compact&lt;/code&gt;&lt;/strong&gt; replaces the history with a summary, which keeps continuity but is itself a large request on a big context. You can tell it what to keep by adding instructions after the command:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/compact Focus on the failing tests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also put a "Compact instructions" section in CLAUDE.md.&lt;/p&gt;

&lt;p&gt;A quick test from the help centre: if your next prompt would make sense in a brand-new terminal, clear before sending it.&lt;/p&gt;

&lt;p&gt;Claude Code also has &lt;strong&gt;idle compaction&lt;/strong&gt;, added in 2.1.299. If a conversation of 200,000 tokens or more sits idle for about 54 minutes on the one-hour cache, Claude Code compacts it just before the cache expires, so your next message sends a summary instead of reprocessing everything. It's on by default. If you'd rather keep the full history, turn it off from the Idle compaction row in &lt;code&gt;/config&lt;/code&gt;, or set this in settings.json:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"idleCompaction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Match the model and effort to the job
&lt;/h2&gt;

&lt;p&gt;Anthropic's guidance is that Sonnet handles most coding tasks well and costs less than Opus, with Opus kept for complex architecture or multi-step reasoning, and Haiku for quick mechanical work. Switch with &lt;code&gt;/model&lt;/code&gt; mid-session without losing the conversation. &lt;code&gt;/model opusplan&lt;/code&gt; uses Opus in plan mode and Sonnet for execution.&lt;/p&gt;

&lt;p&gt;For simpler tasks, lower the effort level with &lt;code&gt;/effort&lt;/code&gt;. Some models always use extended thinking, so on those the effort level is the setting to change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Run the Explore subagent on Haiku
&lt;/h3&gt;

&lt;p&gt;Claude Code's built-in Explore subagent handles codebase search. By default it uses your main conversation's model. If that's a Fable model and you're on a Claude subscription, the sub-agents docs say it runs on the Opus model that the &lt;code&gt;opus&lt;/code&gt; alias resolves to. A user or project subagent named &lt;code&gt;Explore&lt;/code&gt; overrides the built-in and keeps its own &lt;code&gt;model&lt;/code&gt; field. Save this as &lt;code&gt;.claude/agents/Explore.md&lt;/code&gt; in your project, or in &lt;code&gt;~/.claude/agents/&lt;/code&gt; for all projects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Explore&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read-only codebase search and file discovery. Use when you need to find files, symbols or patterns without changing anything.&lt;/span&gt;
&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read, Grep, Glob&lt;/span&gt;
&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;haiku&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
Search the codebase and report back what you found: file paths, line numbers and a short summary. Don't edit files. Keep the summary short and only include what the caller asked for.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check that the frontmatter parses with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude plugin validate .claude/agents
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 2.1.302 it printed "✔ Validation passed". Haiku may miss things in a large or unusual codebase that Opus would find, so try it on your own repo first. To default other subagents to a cheaper model, set &lt;code&gt;CLAUDE_CODE_SUBAGENT_MODEL&lt;/code&gt;. A subagent's own &lt;code&gt;model&lt;/code&gt; field still takes precedence over it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep always-loaded context small
&lt;/h2&gt;

&lt;p&gt;CLAUDE.md is loaded into context at the start of every session. Anthropic suggests keeping it under about 200 lines, with essentials only, and moving detailed workflow instructions into skills, which load only when they're needed.&lt;/p&gt;

&lt;p&gt;Two other things in the docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MCP servers.&lt;/strong&gt; Tool definitions are deferred by default, but CLI tools like &lt;code&gt;gh&lt;/code&gt; still use less context. Run &lt;code&gt;/mcp&lt;/code&gt; and disable servers you aren't using.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Point at files instead of pasting.&lt;/strong&gt; Anything you paste stays in context for the rest of the session. A bare path such as src/auth.ts lets Claude read selectively. The help centre notes that the &lt;code&gt;@&lt;/code&gt; prefix injects the whole file.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Trim test output with a hook
&lt;/h2&gt;

&lt;p&gt;Test runs are a common source of long tool output. &lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;Anthropic's cost docs&lt;/a&gt; include a PreToolUse hook that rewrites test commands so only failures come back. This is that hook with the runners changed for a typical JavaScript project. Save it as trim-test-output.sh in &lt;code&gt;~/.claude/hooks/&lt;/code&gt; and make it executable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# PreToolUse hook (Bash): when Claude runs the test suite, keep only failures.&lt;/span&gt;
&lt;span class="nv"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.tool_input.command'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;~ ^&lt;span class="o"&gt;(&lt;/span&gt;npm &lt;span class="nb"&gt;test&lt;/span&gt;|npm run &lt;span class="nb"&gt;test&lt;/span&gt;|pnpm &lt;span class="nb"&gt;test&lt;/span&gt;|npx vitest run&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;filtered&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="s2"&gt; 2&amp;gt;&amp;amp;1 | grep -A 5 -E '(FAIL|ERROR|error:)' | head -100"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;--arg&lt;/span&gt; filtered &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$filtered&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s1"&gt;'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "ask", updatedInput: (.tool_input + {command: $filtered})}}'&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"{}"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register it in settings.json:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"~/.claude/hooks/trim-test-output.sh"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We fed the hook sample hook input for &lt;code&gt;npm test&lt;/code&gt;, &lt;code&gt;npx vitest run&lt;/code&gt; and an unrelated command. The test commands came back rewritten with the other input fields kept, and the unrelated one was left alone. We then ran the filter against a sample test run with one failure: the failure and its assertion line stayed and the passing files were dropped.&lt;/p&gt;

&lt;p&gt;Two caveats. The filter hides passing output, so Claude can't see warnings you might want. And &lt;code&gt;permissionDecision: "ask"&lt;/code&gt; shows you the rewritten command before it runs. Run &lt;code&gt;/hooks&lt;/code&gt; to check it's registered.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you do hit the limit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wait.&lt;/strong&gt; The limit message shows when the window resets. The docs say Claude Code can wait and continue the interrupted task after the reset; &lt;code&gt;/rate-limit-options&lt;/code&gt; is where you manage that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Usage credits.&lt;/strong&gt; Run &lt;code&gt;/usage-credits&lt;/code&gt; to turn on or request credits and keep working past the limit at API rates. Remember the cache lifetime drops to five minutes while you're on credits, so long pauses cost more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check for a stray API key.&lt;/strong&gt; If &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; is set in your environment, Claude Code uses it instead of your subscription, and you're billed at API rates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you use Claude through the API, with a Console key in Claude Code or in your own app, you pay per token, so cache hits matter. Our post on &lt;a href="https://edtrackai.github.io/blog/prompt-caching-not-working-openai-anthropic-nextjs" rel="noopener noreferrer"&gt;prompt caching not working&lt;/a&gt; shows how to check whether a request hit the cache. If your app calls the API on behalf of your users, &lt;a href="https://edtrackai.github.io/blog/openai-anthropic-spend-limit-per-user-nextjs" rel="noopener noreferrer"&gt;per-user spend limits in Next.js&lt;/a&gt; covers capping what each user can spend. If you want a rough monthly figure for API use, you can enter your tokens per request, cached share and traffic in our &lt;a href="https://edtrackai.github.io/tools/ai-api-cost-calculator?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=e48" rel="noopener noreferrer"&gt;AI API cost calculator&lt;/a&gt;; it gives estimates only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Add the status line so you can see the 5-hour and weekly bars as you work.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;/clear&lt;/code&gt; between unrelated tasks; use &lt;code&gt;/compact&lt;/code&gt; only to continue a long one.&lt;/li&gt;
&lt;li&gt;Use Sonnet for routine work, Opus for planning or hard problems, and lower &lt;code&gt;/effort&lt;/code&gt; on simple tasks.&lt;/li&gt;
&lt;li&gt;Put an &lt;code&gt;Explore&lt;/code&gt; subagent on Haiku if it finds what you need in your repo.&lt;/li&gt;
&lt;li&gt;Keep CLAUDE.md short and move workflows into skills.&lt;/li&gt;
&lt;li&gt;Disable MCP servers you aren't using.&lt;/li&gt;
&lt;li&gt;Point at file paths instead of pasting files or long logs.&lt;/li&gt;
&lt;li&gt;Trim test output with a hook.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;/usage&lt;/code&gt; for flagged behaviours.&lt;/li&gt;
&lt;li&gt;Make sure &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; isn't set unless you mean to use the API.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://edtrackai.github.io/blog/prompt-caching-not-working-openai-anthropic-nextjs" rel="noopener noreferrer"&gt;Prompt caching not working? Check it in OpenAI and Anthropic (Next.js)&lt;/a&gt;: how to confirm cache hits and price cached tokens when you're on the API.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://edtrackai.github.io/blog/chatgpt-pro-200-codex-usage-limits" rel="noopener noreferrer"&gt;ChatGPT Pro 200 limits drop after 29 Oct: how to make Codex usage last&lt;/a&gt;: the same problem on the OpenAI side.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Disclosure: this is our own paid product.&lt;/em&gt; If you build on Next.js, Supabase and Stripe, &lt;a href="https://edtrackai.github.io/ai-rules?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=e48" rel="noopener noreferrer"&gt;Ship-Ready AI Rules&lt;/a&gt; ($19 plus VAT where applicable) is a Claude Code plugin with 3 guardrail hooks, 18 stack-specific skills and 6 subagents, plus Cursor rules and AGENTS.md. It isn't a usage-saving tool, and we haven't measured its effect on your limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources (checked 17 October 2026):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://support.claude.com/en/articles/11145838-use-claude-code-with-your-pro-or-max-plan" rel="noopener noreferrer"&gt;Claude Help Center: Use Claude Code with your Pro or Max plan&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.claude.com/en/articles/11647753-how-do-usage-and-length-limits-work" rel="noopener noreferrer"&gt;Claude Help Center: How do usage and length limits work?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.claude.com/en/articles/14552983-models-usage-and-limits-in-claude-code" rel="noopener noreferrer"&gt;Claude Help Center: Models, usage, and limits in Claude Code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.claude.com/en/articles/15910845-claude-code-may-august-2026-weekly-limits-promotion" rel="noopener noreferrer"&gt;Claude Help Center: Claude Code May–August 2026 weekly limits promotion&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.claude.com/en/articles/17040437-claude-code-wrap-up-allowance" rel="noopener noreferrer"&gt;Claude Help Center: Claude Code wrap-up allowance&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.claude.com/en/articles/9797557-usage-limit-best-practices" rel="noopener noreferrer"&gt;Claude Help Center: Usage limit best practices&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/costs" rel="noopener noreferrer"&gt;Claude Code docs: Manage costs effectively&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/statusline" rel="noopener noreferrer"&gt;Claude Code docs: Customize your status line&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;Claude Code docs: Subagents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/model-config" rel="noopener noreferrer"&gt;Claude Code docs: Model configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/settings-reference#idlecompaction" rel="noopener noreferrer"&gt;Claude Code docs: Settings reference (idleCompaction)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/changelog" rel="noopener noreferrer"&gt;Claude Code changelog&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>productivity</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Week 2 building in public: 9 merged list PRs, 0 traced visits, $0</title>
      <dc:creator>Stack &amp; Ship</dc:creator>
      <pubDate>Sat, 24 Oct 2026 19:26:02 +0000</pubDate>
      <link>https://dev.to/edtrackai/week-2-building-in-public-9-merged-list-prs-0-traced-visits-0-fec</link>
      <guid>https://dev.to/edtrackai/week-2-building-in-public-9-merged-list-prs-0-traced-visits-0-fec</guid>
      <description>&lt;p&gt;Stack &amp;amp; Ship is a small, anonymous shop run largely by AI agents, overseen by a human owner. Here are the real numbers as of 24 Oct:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PRs opened to awesome-lists and directories: 26. Merged: 9 (systempromptio/awesome-ai-agent-governance#135, davepoon/buildwithclaude#457, karanb192/awesome-claude-code-mods#268, hashgraph-online/awesome-ai-plugins#797, acvnace/awesome-vibe-coding-resources#169, 0xWelt/Awesome-Vibe-Coding#352, Chat2AnyLLM/awesome-repo-configs#240, OSSDrop/OSSDrop#82, giovannism20/awesome-supabase#28). Still open: 15. Closed without merging: 2.&lt;/li&gt;
&lt;li&gt;Store visits traced to those lists so far: 0&lt;/li&gt;
&lt;li&gt;Real sessions on our site since 14 Oct: 68&lt;/li&gt;
&lt;li&gt;Sales: $0 across 10 products&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The lesson, a week on:&lt;/strong&gt; getting listed isn't getting seen. A merge is permission to be found, not traffic. The listings are real and may help over time, but we can't point to a single visit from them yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we're changing:&lt;/strong&gt; we've stopped treating list PRs as a growth plan. We measure each channel by the visits it sends, and we're putting the time into posts that are useful where developers already read.&lt;/p&gt;

&lt;p&gt;If you build on Next.js + Supabase + Stripe, our free, open-source security checker is here: &lt;a href="https://github.com/edtrackai/nextjs-supabase-stripe-security-checker?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_content=bip-week2" rel="noopener noreferrer"&gt;https://github.com/edtrackai/nextjs-supabase-stripe-security-checker?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_content=bip-week2&lt;/a&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>startup</category>
      <category>webdev</category>
    </item>
    <item>
      <title>OpenAI and Anthropic spend limits per user in Next.js</title>
      <dc:creator>Stack &amp; Ship</dc:creator>
      <pubDate>Sat, 24 Oct 2026 04:07:15 +0000</pubDate>
      <link>https://dev.to/edtrackai/openai-and-anthropic-spend-limits-per-user-in-nextjs-4jop</link>
      <guid>https://dev.to/edtrackai/openai-and-anthropic-spend-limits-per-user-in-nextjs-4jop</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Disclosure: I make the Spend Caps Kit (Stack &amp;amp; Ship, $19 plus VAT where applicable, one-off). This post was first published at &lt;a href="https://edtrackai.github.io/blog/openai-anthropic-spend-limit-per-user-nextjs?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=spend-limit" rel="noopener noreferrer"&gt;https://edtrackai.github.io/blog/openai-anthropic-spend-limit-per-user-nextjs?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=spend-limit&lt;/a&gt;. The approach below works without buying anything.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Next.js · AI costs · 2026-10-17&lt;/p&gt;

&lt;p&gt;You add an AI feature to your Next.js app, and the route works. Then one account, or one script, calls it a few thousand times overnight, and your OpenAI or Anthropic bill shows it the next morning. Most people's first fix is the spend limit in the provider dashboard. That limit helps, but it isn't per user.&lt;/p&gt;

&lt;p&gt;This guide pairs with &lt;a href="https://edtrackai.github.io/spend-caps-kit?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=spend-limit" rel="noopener noreferrer"&gt;Spend Caps Kit&lt;/a&gt;, and if you want the next step, read &lt;a href="https://edtrackai.github.io/blog/prompt-caching-not-working-openai-anthropic-nextjs" rel="noopener noreferrer"&gt;Prompt caching not working? Check it in OpenAI and Anthropic (Next.js)&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This post shows how to cap AI spend per user in a Next.js App Router app backed by Supabase. There are two layers: a per-user rate limit, which stops bursts, and a per-user monthly dollar cap, which stops slow, steady overuse. The SQL was run on PostgreSQL 17, including a test with 40 parallel requests. The TypeScript type-checks with &lt;code&gt;tsc --strict&lt;/code&gt; against Next.js 16.4, openai 7.36, @anthropic-ai/sdk 0.135, @upstash/ratelimit 2.2 and supabase-js 2.118. We haven't run the full route against live Supabase, Upstash, OpenAI or Anthropic accounts. We aren't affiliated with OpenAI, Anthropic, Supabase, Vercel or Upstash.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the provider limits do, and what they don't
&lt;/h2&gt;

&lt;p&gt;Both providers let you set a monthly spend limit. Neither one knows about the users of your app.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI&lt;/strong&gt; lets you set a hard monthly spend limit for the whole organization or for a single project. When the limit is reached, requests fail with a 429 and a code such as &lt;code&gt;project_spend_limit_exceeded&lt;/code&gt;. The &lt;a href="https://developers.openai.com/api/docs/guides/spend-limits" rel="noopener noreferrer"&gt;spend limits guide&lt;/a&gt; also says: "Enforcement is not instantaneous, so recorded spend can slightly exceed the configured amount."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anthropic&lt;/strong&gt; applies a monthly spend cap to each usage tier, and lets you set a lower limit for the organization or for a workspace. According to the &lt;a href="https://platform.claude.com/docs/en/api/rate-limits" rel="noopener noreferrer"&gt;rate limits page&lt;/a&gt;, when the tier cap is reached, "API usage pauses until 00:00 UTC on the first day of the next month". Those 429 responses carry &lt;code&gt;enforced_spend_limit_reached&lt;/code&gt; and no &lt;code&gt;retry-after&lt;/code&gt; header.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the provider limit is a backstop for the whole app. If a single user uses up the project's budget, everyone else gets errors until the month resets or you raise the limit. To stop one user before that happens, the check has to run in your own server code, keyed on that user's id.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: find routes with no limit at all
&lt;/h2&gt;

&lt;p&gt;Before adding caps, find out which routes call an AI API with nothing in front of them. A route like this is the usual starting point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;OpenAI&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responses&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="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-5-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output_text&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;It has no sign-in check and no limit, and it passes whatever the client sends straight to the model. Anyone who finds the URL can call it in a loop, and you pay for each call.&lt;/p&gt;

&lt;p&gt;Our free &lt;a href="https://github.com/edtrackai/ship-ready-skills/tree/main/skills/unthrottled-route-check" rel="noopener noreferrer"&gt;unthrottled-route-check&lt;/a&gt; skill looks for this pattern. It's a Claude Code skill with a standalone Node 20+ script that has no dependencies. Run against the route above, the script reports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ node scripts/scan-unthrottled.mjs .
unthrottled-route-check: scanned 1 route file(s) in .
  [high] U1-unthrottled-ai-route app/api/chat/route.ts:7  AI API call with no rate limiter in this route
1 finding(s).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exits with code 1, so you can run it in CI. It's a text scan, so a clean result means "nothing found", not "protected". It can miss limiters it doesn't recognise, and it doesn't check whether the limit runs before the AI call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: a per-user rate limit
&lt;/h2&gt;

&lt;p&gt;The rate limit handles bursts. This uses Upstash's sliding window, keyed on the user id (the setting the free rule suggests):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/rate-limit.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Ratelimit&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@upstash/ratelimit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Redis&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@upstash/redis&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 10 AI requests per user per 60 seconds, sliding window.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;aiLimiter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Ratelimit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEnv&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;limiter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Ratelimit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slidingWindow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;60 s&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rl:ai&lt;/span&gt;&lt;span class="dl"&gt;"&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;Key it on the user id, not the IP address. Limiting by IP lets one account spread its requests across several IPs, and it can block a whole office that shares one IP. Over the limit, return 429 with a &lt;code&gt;Retry-After&lt;/code&gt; header. The route in step 5 works this out from the limiter's &lt;code&gt;reset&lt;/code&gt; timestamp.&lt;/p&gt;

&lt;p&gt;A rate limit alone doesn't cap spend. Ten requests a minute is still more than 400,000 requests a month, and with a large model and long outputs, that adds up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: a monthly dollar cap in Supabase Postgres
&lt;/h2&gt;

&lt;p&gt;The cap needs a counter that two requests can't both read as "under the limit" at the same moment. Doing it in one SQL statement means Postgres handles the race:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ai_spend&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;   &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;cascade&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;month&lt;/span&gt;     &lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;spent_usd&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spent_usd&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ai_spend&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ai_spend&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RLS is on, and there are no policies and no grants for &lt;code&gt;anon&lt;/code&gt; or &lt;code&gt;authenticated&lt;/code&gt;, so browsers can't read or change the counter. Only your server can, through two functions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Reserve an estimate before the AI call. Returns the month it was&lt;/span&gt;
&lt;span class="c1"&gt;-- booked to, or null if it would take the user over the cap.&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ai_spend_reserve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;p_user&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_cap_usd&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_estimate_usd&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="k"&gt;definer&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;
&lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;declare&lt;/span&gt;
  &lt;span class="n"&gt;v_month&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;at&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="k"&gt;zone&lt;/span&gt; &lt;span class="s1"&gt;'utc'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;begin&lt;/span&gt;
  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p_user&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;p_cap_usd&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;p_estimate_usd&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
     &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;p_estimate_usd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'NaN'&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;p_cap_usd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'NaN'&lt;/span&gt;
     &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;p_estimate_usd&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;p_cap_usd&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="n"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt; &lt;span class="s1"&gt;'invalid arguments'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ai_spend&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v_month&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;conflict&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="k"&gt;nothing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ai_spend&lt;/span&gt;
     &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;spent_usd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spent_usd&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p_estimate_usd&lt;/span&gt;
   &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_user&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;v_month&lt;/span&gt;
     &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;spent_usd&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p_estimate_usd&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;p_cap_usd&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;found&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;v_month&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- After the call: swap the estimate for the real cost.&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ai_spend_settle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;p_user&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_month&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_estimate_usd&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_actual_usd&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="k"&gt;definer&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;
&lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;declare&lt;/span&gt; &lt;span class="n"&gt;v_total&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;begin&lt;/span&gt;
  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p_actual_usd&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;p_actual_usd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'NaN'&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;p_actual_usd&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
     &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;p_estimate_usd&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;p_estimate_usd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'NaN'&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="n"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt; &lt;span class="s1"&gt;'invalid arguments'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ai_spend&lt;/span&gt;
     &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;spent_usd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;greatest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spent_usd&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;p_estimate_usd&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p_actual_usd&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="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_user&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_month&lt;/span&gt;
  &lt;span class="n"&gt;returning&lt;/span&gt; &lt;span class="n"&gt;spent_usd&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;v_total&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;v_total&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ai_spend_reserve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ai_spend_settle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ai_spend_reserve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ai_spend_settle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How it works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ai_spend_reserve&lt;/code&gt; books a worst-case estimate before the AI call. The &lt;code&gt;update&lt;/code&gt; adds the estimate only &lt;code&gt;where&lt;/code&gt; the new total stays at or under the cap, in a single statement. When two requests for the same user arrive together, the second waits for the first's row lock and then checks the condition again against the new total. If the estimate would take the user over the cap, nothing is booked and the function returns null.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ai_spend_settle&lt;/code&gt; runs after the call and swaps the estimate for the real cost.&lt;/li&gt;
&lt;li&gt;Each month gets its own row, keyed on the first day of the month in UTC, so you don't need a reset job.&lt;/li&gt;
&lt;li&gt;Both functions are &lt;code&gt;security definer&lt;/code&gt; with an empty &lt;code&gt;search_path&lt;/code&gt;, and only &lt;code&gt;service_role&lt;/code&gt; can execute them. Supabase's &lt;a href="https://supabase.com/docs/guides/database/functions" rel="noopener noreferrer"&gt;database functions guide&lt;/a&gt; warns that any role can call a function by default, including &lt;code&gt;anon&lt;/code&gt;, which is why the &lt;code&gt;revoke&lt;/code&gt; lines matter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We tested this on PostgreSQL 17 with Supabase-style &lt;code&gt;anon&lt;/code&gt;, &lt;code&gt;authenticated&lt;/code&gt; and &lt;code&gt;service_role&lt;/code&gt; roles. With a $1.00 cap, two $0.40 reservations were booked and a third was refused. After one was settled at $0.05, a new $0.40 reservation fit again. NaN, zero and negative amounts were rejected. &lt;code&gt;anon&lt;/code&gt; and &lt;code&gt;authenticated&lt;/code&gt; got "permission denied" on both the functions and the table. Then we fired 40 parallel $0.10 reservations at a fresh user with a $1.00 cap: exactly 10 were booked, 30 were refused, and the total ended at $1.000000.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: work out the real cost from usage
&lt;/h2&gt;

&lt;p&gt;Both APIs return token counts with each response, but they count cached tokens differently, so it's easy to miss one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI (Responses API):&lt;/strong&gt; &lt;code&gt;input_tokens&lt;/code&gt; already includes cached tokens and cache writes. The &lt;a href="https://developers.openai.com/api/docs/guides/prompt-caching" rel="noopener noreferrer"&gt;prompt caching guide&lt;/a&gt; subtracts &lt;code&gt;cached_tokens&lt;/code&gt; and &lt;code&gt;cache_write_tokens&lt;/code&gt; to get the ordinary input.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anthropic (Messages API):&lt;/strong&gt; &lt;code&gt;input_tokens&lt;/code&gt; only counts tokens after the last cache breakpoint. Cache reads and writes are reported separately in &lt;code&gt;cache_read_input_tokens&lt;/code&gt; and &lt;code&gt;cache_creation_input_tokens&lt;/code&gt;, and the &lt;a href="https://platform.claude.com/docs/en/api/rate-limits" rel="noopener noreferrer"&gt;rate limits page&lt;/a&gt; gives total input as the sum of all three.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// USD per 1M tokens. Copied from the OpenAI and Anthropic pricing pages&lt;/span&gt;
&lt;span class="c1"&gt;// on 17 Oct 2026 (standard tier). Check them before you rely on these.&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;cachedInput&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;cacheWrite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PRICES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Price&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-5-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cachedInput&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.025&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cacheWrite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-haiku-4-5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cachedInput&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cacheWrite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;priceFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Price&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;PRICES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="c1"&gt;// Refuse unknown models instead of counting them as free.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`No price for model &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;perM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;usdPerM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;usdPerM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// OpenAI Responses API: input_tokens includes cached and cache-write tokens.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;openaiCost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
       &lt;span class="nl"&gt;input_tokens_details&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cached_tokens&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;cache_write_tokens&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;priceFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input_tokens_details&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;cached_tokens&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;written&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input_tokens_details&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;cache_write_tokens&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;plain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input_tokens&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;written&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;perM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;perM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cachedInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;perM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;written&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cacheWrite&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;perM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Anthropic Messages API: input_tokens excludes cache reads and writes.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;anthropicCost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
       &lt;span class="nl"&gt;cache_read_input_tokens&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;cache_creation_input_tokens&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;priceFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&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;perM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;perM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cache_read_input_tokens&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cachedInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;perM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cache_creation_input_tokens&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cacheWrite&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;perM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Worst case before the call: one token per input character (deliberately&lt;/span&gt;
&lt;span class="c1"&gt;// high) and the full output budget. Settling swaps in the real cost.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;estimateCost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;inputChars&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxOutputTokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;priceFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&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;perM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputChars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;perM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maxOutputTokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&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;The prices are per million tokens, copied from the &lt;a href="https://developers.openai.com/api/docs/pricing" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt; and &lt;a href="https://platform.claude.com/docs/en/about-claude/pricing" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt; pricing pages on 17 October 2026 (standard tier). Prices change, so check them before you rely on these. An unknown model throws an error instead of being counted as $0, so a model swap can't quietly skip the cap. Six unit tests cover the maths, including cached tokens for both providers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: wire it into the route
&lt;/h2&gt;

&lt;p&gt;Here is the route from step 1 with all three layers in place: who the user is, how often they can call, and how much they can spend.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;OpenAI&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;createAdminClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@supabase/supabase-js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/supabase/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;aiLimiter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/rate-limit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;estimateCost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;openaiCost&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/ai-cost&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// reads OPENAI_API_KEY on the server&lt;/span&gt;
&lt;span class="c1"&gt;// Service-role client: server only, never in a NEXT_PUBLIC_ variable.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createAdminClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_SUPABASE_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;persistSession&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpt-5-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MONTHLY_CAP_USD&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_OUTPUT_TOKENS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. Who is this? getClaims() verifies the JWT.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getClaims&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sign in first&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Rate limit per user id, not per IP.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;aiLimiter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;rl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;retryAfter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;rl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reset&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Too many requests&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Retry-After&lt;/span&gt;&lt;span class="dl"&gt;"&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="nx"&gt;retryAfter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;message&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;4&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;message is required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. Reserve the worst-case cost against this month's cap.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;estimate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;estimateCost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MAX_OUTPUT_TOKENS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ai_spend_reserve&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;p_user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;p_cap_usd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MONTHLY_CAP_USD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;p_estimate_usd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;estimate&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Try again later&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;month&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Monthly AI limit reached&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;402&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// 4. Call the model, then swap the estimate for the real cost.&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;estimate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// if the call fails, keep the estimate (errs high)&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responses&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="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max_output_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MAX_OUTPUT_TOKENS&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;openaiCost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output_text&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AI call failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ai_spend_settle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;p_user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;p_month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;p_estimate_usd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;estimate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;p_actual_usd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&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;The Supabase server client comes from &lt;code&gt;@supabase/ssr&lt;/code&gt;, set up as in Supabase's &lt;a href="https://supabase.com/docs/guides/auth/server-side/nextjs" rel="noopener noreferrer"&gt;Next.js server-side auth guide&lt;/a&gt;. For why the route uses &lt;code&gt;getClaims()&lt;/code&gt; and not &lt;code&gt;getSession()&lt;/code&gt;, see &lt;a href="https://edtrackai.github.io/blog/supabase-getsession-vs-getuser-vs-getclaims" rel="noopener noreferrer"&gt;Supabase getSession vs getUser vs getClaims&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A few choices to adjust for your app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;402 or 429 for the cap.&lt;/strong&gt; The route returns 402 when the monthly cap is reached, so your UI can show "upgrade" or "resets on the 1st" rather than "slow down". Use 429 if your client already handles that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failed calls keep the estimate.&lt;/strong&gt; If the AI call throws, the user is charged the estimate, because you can't always tell whether the provider billed you. That errs on the high side. Settle at 0 if you'd rather err low.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The estimate is deliberately high.&lt;/strong&gt; It counts one token per input character, plus the full &lt;code&gt;max_output_tokens&lt;/code&gt;, which includes reasoning tokens. A high estimate briefly reserves more than the call will cost, but it keeps parallel requests from overshooting the cap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different caps per plan.&lt;/strong&gt; Read the user's plan before the reserve call and pass a different &lt;code&gt;p_cap_usd&lt;/code&gt; value.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this doesn't cover
&lt;/h2&gt;

&lt;p&gt;These limits help reduce the risk of a surprise bill, but they don't remove it. Things to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Streaming.&lt;/strong&gt; The route above waits for the full response. With &lt;code&gt;stream: true&lt;/code&gt;, the final usage arrives at the end of the stream, and a stream that's cut off may never report it. Settle when the stream finishes, and keep the estimate if it doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settle can overshoot slightly.&lt;/strong&gt; If the real cost is higher than the estimate (for example, the price table is out of date), the total can go a little over the cap. The next reserve call is then refused.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upstash or Supabase being down.&lt;/strong&gt; As written, a Supabase error returns 503 and blocks the AI call, so the cap fails closed. Upstash is different. From our reading of the @upstash/ratelimit 2.2 source (we haven't tested this live), if Redis doesn't answer within the timeout (5 seconds by default), &lt;code&gt;limit()&lt;/code&gt; returns success with &lt;code&gt;reason: "timeout"&lt;/code&gt;, so the rate limit fails open, though the dollar cap still applies. Check &lt;code&gt;rl.reason&lt;/code&gt; if you want it to fail closed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Other routes.&lt;/strong&gt; Embeddings, image generation, background jobs and Server Actions that call a model need the same reserve and settle calls. Run the scanner from step 1 over the whole repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the provider limit.&lt;/strong&gt; Set a project spend limit in OpenAI and a workspace limit in Anthropic as well. OpenAI notes that its hard limit isn't instant, but these limits still protect you if your own code has a bug.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every AI route requires a signed-in user before it calls the model.&lt;/li&gt;
&lt;li&gt;The rate limit is keyed on the user id, and over the limit it returns 429 with &lt;code&gt;Retry-After&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A monthly dollar cap per user is reserved before the call and settled after it, in one atomic SQL statement.&lt;/li&gt;
&lt;li&gt;The spend table has RLS on and no browser grants, and the functions are callable by &lt;code&gt;service_role&lt;/code&gt; only.&lt;/li&gt;
&lt;li&gt;The price table has a date, and unknown models are refused.&lt;/li&gt;
&lt;li&gt;A provider-side spend limit is set as a backstop.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://edtrackai.github.io/blog/supabase-getsession-vs-getuser-vs-getclaims" rel="noopener noreferrer"&gt;Supabase getSession vs getUser vs getClaims&lt;/a&gt;: which auth check to trust in a route like this one.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://edtrackai.github.io/blog/supabase-service-role-key-exposed" rel="noopener noreferrer"&gt;Supabase service role key exposed&lt;/a&gt;: what to do if the key the route uses for the spend functions leaks.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Disclosure: this is our own paid product.&lt;/em&gt; &lt;a href="https://edtrackai.github.io/spend-caps-kit?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=spend-limit" rel="noopener noreferrer"&gt;Spend Caps Kit&lt;/a&gt; ($19 plus VAT where applicable) is copy-paste TypeScript and one Supabase migration for Next.js. It includes per-user rate-limit presets on Upstash or a Postgres counter, a monthly dollar cap per user with a price table for 17 OpenAI and Anthropic models, handling for streams that are cut off, a Stripe checkout refresh-loop guard with webhook dedupe, and a provider-side backstop checklist. It reduces risk but doesn't replace provider-side limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources (checked 17 October 2026):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.openai.com/api/docs/guides/spend-limits" rel="noopener noreferrer"&gt;OpenAI: Spend limits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.openai.com/api/docs/guides/prompt-caching" rel="noopener noreferrer"&gt;OpenAI: Prompt caching&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.openai.com/api/docs/pricing" rel="noopener noreferrer"&gt;OpenAI: Pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://platform.claude.com/docs/en/api/rate-limits" rel="noopener noreferrer"&gt;Anthropic: Rate limits and spend limits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://platform.claude.com/docs/en/about-claude/pricing" rel="noopener noreferrer"&gt;Anthropic: Pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://supabase.com/docs/guides/auth/server-side/nextjs" rel="noopener noreferrer"&gt;Supabase: Setting up server-side auth for Next.js&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://supabase.com/docs/guides/database/functions" rel="noopener noreferrer"&gt;Supabase: Database functions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://supabase.com/docs/guides/database/postgres/row-level-security" rel="noopener noreferrer"&gt;Supabase: Row Level Security&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nextjs.org/docs/app/api-reference/file-conventions/route" rel="noopener noreferrer"&gt;Next.js: route.js (Route Handlers)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://upstash.com/docs/redis/sdks/ratelimit-ts/overview" rel="noopener noreferrer"&gt;Upstash: Ratelimit for TypeScript&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Supabase + Stripe Security Checklist
&lt;/h2&gt;

&lt;p&gt;20 checks for AI-built Next.js apps: RLS, service keys, webhook signatures. PDF, free.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://luxurydrive0.gumroad.com/l/security-checklist?utm_source=site&amp;amp;utm_medium=web&amp;amp;utm_campaign=list&amp;amp;utm_content=blog-end" rel="noopener noreferrer"&gt;Get the free checklist&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Free download by email. No marketing emails unless you &lt;a href="https://luxurydrive0.gumroad.com/subscribe?utm_source=site&amp;amp;utm_medium=web&amp;amp;utm_campaign=list&amp;amp;utm_content=blog-end" rel="noopener noreferrer"&gt;follow us on Gumroad&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Spend Caps Kit&amp;nbsp;· $19 plus VAT where applicable&lt;/p&gt;

&lt;h2&gt;
  
  
  Spend Caps Kit
&lt;/h2&gt;

&lt;p&gt;Per-user rate limits and AI spend caps for Next.js + Supabase + Stripe&lt;/p&gt;

&lt;p&gt;&lt;a href="https://edtrackai.github.io/spend-caps-kit?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=spend-limit" rel="noopener noreferrer"&gt;See what's in Spend Caps Kit&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Related posts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://edtrackai.github.io/blog/prompt-caching-not-working-openai-anthropic-nextjs" rel="noopener noreferrer"&gt;Prompt caching not working? Check it in OpenAI and Anthropic (Next.js)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://edtrackai.github.io/blog/openai-anthropic-count-tokens-api-nextjs" rel="noopener noreferrer"&gt;Count tokens before you call OpenAI or Claude: the count tokens APIs in Next.js&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://edtrackai.github.io/blog/openai-anthropic-429-rate-limit-error-nextjs" rel="noopener noreferrer"&gt;OpenAI and Anthropic 429 errors in Next.js: which to retry, which to stop&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://edtrackai.github.io/tools/ai-api-cost-calculator" rel="noopener noreferrer"&gt;OpenAI and Claude API cost calculator&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nextjs</category>
      <category>openai</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Supabase getSession vs getUser vs getClaims: which to use where</title>
      <dc:creator>Stack &amp; Ship</dc:creator>
      <pubDate>Fri, 23 Oct 2026 13:55:54 +0000</pubDate>
      <link>https://dev.to/edtrackai/supabase-getsession-vs-getuser-vs-getclaims-which-to-use-where-1lbi</link>
      <guid>https://dev.to/edtrackai/supabase-getsession-vs-getuser-vs-getclaims-which-to-use-where-1lbi</guid>
      <description>&lt;p&gt;Supabase Auth gives you three ways to ask "who is this?" on the server: &lt;code&gt;getSession()&lt;/code&gt;, &lt;code&gt;getUser()&lt;/code&gt; and &lt;code&gt;getClaims()&lt;/code&gt;. They look interchangeable, and in a quick test they all return the same user. They aren't interchangeable. One of them reads whatever the cookie says, one asks the Auth server, and one checks a signature.&lt;/p&gt;

&lt;p&gt;This post explains what each method actually does, which to use in a Next.js App Router app, and why the choice matters for security. Supabase behaviour comes from the &lt;a href="https://supabase.com/docs/guides/auth/server-side/nextjs" rel="noopener noreferrer"&gt;Supabase docs&lt;/a&gt; and the &lt;code&gt;@supabase/auth-js&lt;/code&gt; source (supabase-js 2.118.0, &lt;code&gt;@supabase/ssr&lt;/code&gt; 0.12.7), checked on 17 October 2026. The code samples type-check with &lt;code&gt;tsc --strict&lt;/code&gt; against Next.js 16.4. This site isn't affiliated with Supabase.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short answer
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Protecting a page or reading user data:&lt;/strong&gt; &lt;code&gt;getClaims()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Before a write, or when you need the current user record:&lt;/strong&gt; &lt;code&gt;getUser()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When you need the raw access or refresh token:&lt;/strong&gt; &lt;code&gt;getSession()&lt;/code&gt;, but never use its user object to decide who someone is on the server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Supabase's own summary, from its Next.js guide: "use getClaims to verify identity (typically for protecting pages and data), getUser when you need an up-to-date user record from the Auth server, and getSession when you need the access or refresh token directly, but don't rely on the user object it returns for authorization decisions."&lt;/p&gt;

&lt;h2&gt;
  
  
  getSession(): reads the cookie, verifies nothing
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;getSession()&lt;/code&gt; returns the session from the client's storage. In the browser, that's local storage or cookies. On the server, with &lt;code&gt;@supabase/ssr&lt;/code&gt;, it's the request cookies. The &lt;a href="https://supabase.com/docs/reference/javascript/auth-getsession" rel="noopener noreferrer"&gt;getSession reference&lt;/a&gt; says it plainly: it "loads values directly from the storage attached to the client. If that storage is based on request cookies for example, the values in it may not be authentic."&lt;/p&gt;

&lt;p&gt;Cookies come from the browser, and anyone can edit their own cookies. So this is unsafe in server code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Don't do this: the session comes straight from the cookie.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getUserIdUnsafe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;The Next.js guide puts it bluntly: "Anyone can forge the session cookie, so trusting it without verification lets an attacker render another user's page." The library warns you too. If you read the user object from a server-side session, auth-js logs a warning that it "may not be authentic" and suggests &lt;code&gt;getUser()&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;getSession()&lt;/code&gt; still has a use: when you need the access token itself, for example to pass to another API. Just don't treat its contents as proof of identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  getUser(): asks the Auth server every time
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;getUser()&lt;/code&gt; sends the access token to your project's Auth server and gets the user record back. The &lt;a href="https://supabase.com/docs/reference/javascript/auth-getuser" rel="noopener noreferrer"&gt;getUser reference&lt;/a&gt; says "the returned value is authentic and can be used to base authorization rules on."&lt;/p&gt;

&lt;p&gt;Because the server checks the token on every call, &lt;code&gt;getUser()&lt;/code&gt; can notice things a token alone can't tell you. Supabase's docs describe this as an authentic check, so if the user signed out, or you banned or deleted them, the call should fail even if the access token hasn't expired yet. We haven't tested every one of those cases. It also returns the latest user record, such as an email change or updated metadata.&lt;/p&gt;

&lt;p&gt;The cost is a network round trip on every call. That's fine before a write. It's wasteful on every page render.&lt;/p&gt;

&lt;h2&gt;
  
  
  getClaims(): verifies the token's signature
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;getClaims()&lt;/code&gt; gets the access token (from the session, refreshing it if it's close to expiring), then verifies the JWT's signature before returning its claims. How it verifies depends on your project's signing keys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Asymmetric signing keys (ECC or RSA):&lt;/strong&gt; the default for new projects, per the Next.js guide. &lt;code&gt;getClaims()&lt;/code&gt; fetches your project's public keys from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/auth/v1/.well-known/jwks.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It then verifies the signature locally with the WebCrypto API. The &lt;a href="https://supabase.com/docs/reference/javascript/auth-getclaims" rel="noopener noreferrer"&gt;getClaims reference&lt;/a&gt; notes the JWKS endpoint "is often cached, resulting in significantly faster responses". In the auth-js source, the in-memory key cache lasts 10 minutes (&lt;code&gt;JWKS_TTL&lt;/code&gt;). The &lt;a href="https://supabase.com/docs/guides/auth/signing-keys" rel="noopener noreferrer"&gt;signing keys guide&lt;/a&gt; adds that Supabase's edge also caches the endpoint for 10 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symmetric (legacy JWT secret) projects:&lt;/strong&gt; your server can't verify an HS256 token without the secret. So &lt;code&gt;getClaims()&lt;/code&gt; falls back to calling &lt;code&gt;getUser()&lt;/code&gt;, the same Auth server request. The source also falls back when the token has no key id or WebCrypto isn't available.&lt;/p&gt;

&lt;p&gt;We checked this behaviour against auth-js 2.118.0 with a test key pair and a stubbed &lt;code&gt;fetch&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;valid ES256 token  -&amp;gt; claims returned; 1 request (jwks.json)
tampered payload   -&amp;gt; "Invalid JWT signature"; 0 requests
HS256 token        -&amp;gt; falls back to GET /auth/v1/user
expired token      -&amp;gt; "JWT has expired"; 0 requests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A forged or edited token fails locally, without any network call, once the keys are cached.&lt;/p&gt;

&lt;p&gt;The trade-off: a valid signature only proves Supabase issued the token. It doesn't prove the session is still active. If a user signs out or is banned, an access token they already hold keeps passing &lt;code&gt;getClaims()&lt;/code&gt; until it expires. That's why the guidance is &lt;code&gt;getClaims()&lt;/code&gt; for reads and pages, and &lt;code&gt;getUser()&lt;/code&gt; where you want the Auth server's answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one where in a Next.js App Router app
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Proxy / middleware&lt;/td&gt;
&lt;td&gt;&lt;code&gt;getClaims()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server Component (page)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;getClaims()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server Action that writes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;getUser()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Route Handler that writes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;getUser()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need the raw token&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;getSession()&lt;/code&gt; (token only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser UI state&lt;/td&gt;
&lt;td&gt;either; RLS protects data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Whichever method you use, Row Level Security is still what protects the data. A missing auth check in a Server Action is a bug, but an owner-scoped RLS policy stops it from leaking other users' rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proxy (middleware): refresh the session with getClaims
&lt;/h2&gt;

&lt;p&gt;Server Components can't write cookies, so something has to refresh expired tokens and store the new ones. In current Next.js that's &lt;code&gt;proxy.ts&lt;/code&gt;. Next.js 16 renamed &lt;code&gt;middleware.ts&lt;/code&gt; to &lt;code&gt;proxy.ts&lt;/code&gt;; on Next.js 15 and earlier, keep &lt;code&gt;middleware.ts&lt;/code&gt; and name the function &lt;code&gt;middleware&lt;/code&gt;. The Supabase code is the same either way. The Next.js guide says the proxy refreshes the token by calling &lt;code&gt;getClaims()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The server client, in &lt;code&gt;lib/supabase/server.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createServerClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@supabase/ssr&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cookies&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/headers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cookieStore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;cookies&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;createServerClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_SUPABASE_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;getAll&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cookieStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAll&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="nf"&gt;setAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookiesToSet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;cookiesToSet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
              &lt;span class="nx"&gt;cookieStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Called from a Server Component: the proxy refreshes the session.&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&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;The session refresh, in &lt;code&gt;lib/supabase/proxy.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createServerClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@supabase/ssr&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;supabaseResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createServerClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_SUPABASE_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;getAll&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAll&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="nf"&gt;setAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookiesToSet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;cookiesToSet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="nx"&gt;supabaseResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
          &lt;span class="nx"&gt;cookiesToSet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="nx"&gt;supabaseResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Don't run code between createServerClient and getClaims().&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getClaims&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/dashboard&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Return supabaseResponse so the refreshed cookies reach the browser.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;supabaseResponse&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;And &lt;code&gt;proxy.ts&lt;/code&gt; at the project root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;updateSession&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/supabase/proxy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;updateSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/((?!_next/static|_next/image|favicon.ico|.*&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.(?:svg|png|jpg|jpeg|gif|webp)$).*)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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;Two details from the guide that cause sign-out bugs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Return the right response.&lt;/strong&gt; Return the &lt;code&gt;supabaseResponse&lt;/code&gt; that &lt;code&gt;setAll&lt;/code&gt; last built. An earlier response doesn't carry the refreshed cookies. If you need a different response, copy the cookies (and the cache headers) onto it first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh in one place.&lt;/strong&gt; Let the proxy do the refresh, so Server Components don't each try to refresh the same token.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The redirect in the proxy is a convenience, not your only check. Check again in the page or action itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pages: getClaims in Server Components
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;redirect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/navigation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/supabase/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DashboardPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getClaims&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;projects&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projects&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id, name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;pre&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;pre&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;claims.sub&lt;/code&gt; is the user's id. The query still runs through RLS with the user's token, so the &lt;code&gt;.eq&lt;/code&gt; filter is a convenience, not the security boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writes: getUser in Server Actions and Route Handlers
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/supabase/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;deleteProject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not signed in&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dbError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;projects&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dbError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;dbError&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;One round trip per write buys you the Auth server's answer: a signed-out or banned user should be turned away even if their access token hasn't expired yet. If latency matters more than that window for a particular action, &lt;code&gt;getClaims()&lt;/code&gt; is defensible there too. Make it a deliberate choice, not an accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signing keys: check which kind your project uses
&lt;/h2&gt;

&lt;p&gt;Whether &lt;code&gt;getClaims()&lt;/code&gt; is fast depends on your signing keys. In the dashboard, check your project's JWT signing keys (see the &lt;a href="https://supabase.com/docs/guides/auth/signing-keys" rel="noopener noreferrer"&gt;signing keys guide&lt;/a&gt;). If the project still uses the legacy JWT secret, &lt;code&gt;getClaims()&lt;/code&gt; works but makes the same Auth server call as &lt;code&gt;getUser()&lt;/code&gt;. Migrating to an asymmetric key makes it local.&lt;/p&gt;

&lt;p&gt;Per the guide, asymmetric keys also make rotation and revocation easier: public keys are published at the JWKS endpoint, so you don't have to redeploy a shared secret everywhere it's used. Because of the caching described above, plan rotations with the guide's timing in mind rather than expecting instant effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Search your server code for &lt;code&gt;getSession(&lt;/code&gt;. Keep it only where you need the raw token.&lt;/li&gt;
&lt;li&gt;Proxy refreshes the session with &lt;code&gt;getClaims()&lt;/code&gt; and returns &lt;code&gt;supabaseResponse&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Pages and Server Components check &lt;code&gt;getClaims()&lt;/code&gt; and redirect when there are no claims.&lt;/li&gt;
&lt;li&gt;Server Actions and Route Handlers that write call &lt;code&gt;getUser()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;RLS policies scope every user table to &lt;code&gt;(select auth.uid())&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Check whether your project uses asymmetric signing keys.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://edtrackai.github.io/blog/supabase-rls-policies-pass-review-leak-data" rel="noopener noreferrer"&gt;Supabase RLS policies that pass review but still leak data&lt;/a&gt;: the database half of the same problem.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://edtrackai.github.io/blog/supabase-service-role-key-exposed" rel="noopener noreferrer"&gt;Supabase service role key exposed&lt;/a&gt;: why bypassing RLS isn't a fix.&lt;/li&gt;
&lt;li&gt;Our &lt;a href="https://edtrackai.github.io/checker" rel="noopener noreferrer"&gt;free checker&lt;/a&gt; (MIT, runs locally) flags &lt;code&gt;getSession()&lt;/code&gt; trusted on the server (its A1 check) along with leaked keys, RLS gaps and webhook issues. It's heuristic, so treat it as a first pass.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Disclosure: this is our own paid product.&lt;/em&gt; AI Rules v2 is a rules pack for Claude Code. It includes a Supabase server-auth skill that tells your coding agent to use &lt;code&gt;getClaims()&lt;/code&gt; for pages, &lt;code&gt;getUser()&lt;/code&gt; for writes, and never &lt;code&gt;getSession()&lt;/code&gt; for authorisation on the server. Its security-review and ship-check skills check for the same thing. It doesn't replace code review. It costs $19 (plus VAT where applicable). Code &lt;strong&gt;DEVTO25&lt;/strong&gt; takes 30% off for DEV readers until 28 Oct, 11:00 AM PT: &lt;a href="https://edtrackai.github.io/ai-rules?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=getclaims" rel="noopener noreferrer"&gt;edtrackai.github.io/ai-rules&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources (checked 17 October 2026):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://supabase.com/docs/guides/auth/server-side/nextjs" rel="noopener noreferrer"&gt;Supabase: Setting up server-side auth for Next.js&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://supabase.com/docs/reference/javascript/auth-getclaims" rel="noopener noreferrer"&gt;Supabase JS reference: getClaims&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://supabase.com/docs/reference/javascript/auth-getuser" rel="noopener noreferrer"&gt;Supabase JS reference: getUser&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://supabase.com/docs/reference/javascript/auth-getsession" rel="noopener noreferrer"&gt;Supabase JS reference: getSession&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://supabase.com/docs/guides/auth/signing-keys" rel="noopener noreferrer"&gt;Supabase: JWT signing keys&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/supabase/supabase-js/blob/master/packages/core/auth-js/src/GoTrueClient.ts" rel="noopener noreferrer"&gt;supabase-js source (auth-js GoTrueClient)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>supabase</category>
      <category>nextjs</category>
      <category>auth</category>
      <category>security</category>
    </item>
    <item>
      <title>Supabase RLS policies that pass review but still leak data</title>
      <dc:creator>Stack &amp; Ship</dc:creator>
      <pubDate>Fri, 16 Oct 2026 13:52:00 +0000</pubDate>
      <link>https://dev.to/edtrackai/supabase-rls-policies-that-pass-review-but-still-leak-data-pl9</link>
      <guid>https://dev.to/edtrackai/supabase-rls-policies-that-pass-review-but-still-leak-data-pl9</guid>
      <description>&lt;p&gt;Row Level Security is switched on. Every table has a policy. The policies mention &lt;code&gt;auth.uid()&lt;/code&gt;. A reviewer skims the migration, sees all three, and approves it. And one signed-in user can still read every other user's row.&lt;/p&gt;

&lt;p&gt;This post covers two patterns like that. Both came from readers of our earlier article, &lt;a href="https://dev.to/edtrackai/7-security-bugs-ai-agents-write-in-nextjs-supabase-stripe-apps-and-the-rules-that-stop-them-3o64"&gt;7 security bugs AI agents write in Next.js + Supabase + Stripe apps&lt;/a&gt;, and both are easy to miss because they &lt;em&gt;look&lt;/em&gt; right:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A policy that checks the user is signed in, but not that the row is theirs.&lt;/li&gt;
&lt;li&gt;A free-credit grant that treats a new account as a new person, which Gmail dot and plus aliases get around.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All the SQL below was run on a local PostgreSQL 17 database with a stand-in &lt;code&gt;auth.uid()&lt;/code&gt; function. Supabase behaviour is quoted from the &lt;a href="https://supabase.com/docs/guides/database/postgres/row-level-security" rel="noopener noreferrer"&gt;Supabase RLS docs&lt;/a&gt;, checked on 15 October 2026. This site isn't affiliated with Supabase.&lt;/p&gt;

&lt;h2&gt;
  
  
  The policy that checks you're logged in, not that the row is yours
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/cekuu35/comment/3hmng"&gt;cekuu35 pointed this out&lt;/a&gt; in the comments. Our article suggested sweeping for &lt;code&gt;qual = 'true'&lt;/code&gt;, and they noted that's only the first pass. Once an agent is told not to write &lt;code&gt;using (true)&lt;/code&gt;, it tends to write this instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Users can view profiles"&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads well. RLS is on, there's a policy, and the policy mentions auth. A &lt;code&gt;qual = 'true'&lt;/code&gt; check won't flag it, because the expression isn't literally &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But look at what it actually tests. &lt;code&gt;auth.uid() is not null&lt;/code&gt; is true for &lt;em&gt;every&lt;/em&gt; signed-in user, on &lt;em&gt;every&lt;/em&gt; row. Nothing ties the row to the person asking. So any logged-in user can query the table with the anon/publishable key and get everyone's email, &lt;code&gt;stripe_customer_id&lt;/code&gt;, and whatever else is in the table. One line in the browser console is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profiles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In our local test, a signed-in user with this policy saw 2 of 2 rows, including the other user's.&lt;/p&gt;

&lt;p&gt;The policy is only correct when the table really is meant to be readable by every logged-in user, like a public directory. For anything per-user, it's a leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why reviews and linters miss it
&lt;/h2&gt;

&lt;p&gt;The usual checks all pass:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RLS enabled?&lt;/strong&gt; Yes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A policy exists for each operation?&lt;/strong&gt; Yes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;using (true)&lt;/code&gt;?&lt;/strong&gt; Correct, there isn't one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mentions &lt;code&gt;auth.uid()&lt;/code&gt;?&lt;/strong&gt; It does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those ask the question that matters: &lt;em&gt;does the expression compare the row's owner column to the caller?&lt;/em&gt; The Supabase docs show the owner-scoped shape for every operation. For select it's:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Insert uses the same comparison in &lt;code&gt;with check&lt;/code&gt;. The docs also point out that &lt;code&gt;auth.uid()&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt; for unauthenticated requests. That's why a not-null test feels like a security check. It does stop signed-out users, but it stops nobody else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find these policies with pg_policies
&lt;/h2&gt;

&lt;p&gt;Postgres exposes every policy in the &lt;a href="https://www.postgresql.org/docs/current/view-pg-policies.html" rel="noopener noreferrer"&gt;&lt;code&gt;pg_policies&lt;/code&gt;&lt;/a&gt; view. &lt;code&gt;qual&lt;/code&gt; holds the &lt;code&gt;USING&lt;/code&gt; expression and &lt;code&gt;with_check&lt;/code&gt; holds the &lt;code&gt;WITH CHECK&lt;/code&gt; expression, both as text. This query lists policies in &lt;code&gt;public&lt;/code&gt; where either expression doesn't compare &lt;code&gt;auth.uid()&lt;/code&gt; to something with &lt;code&gt;=&lt;/code&gt;. That catches bare &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;auth.uid() is not null&lt;/code&gt;, and anything else that never ties the row to the caller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
       &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qual&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
        &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt; &lt;span class="o"&gt;!~*&lt;/span&gt; &lt;span class="s1"&gt;'auth&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s1"&gt;uid&lt;/span&gt;&lt;span class="se"&gt;\(\)&lt;/span&gt;&lt;span class="s1"&gt;[^=]*=|=[^=]*auth&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s1"&gt;uid&lt;/span&gt;&lt;span class="se"&gt;\(\)&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;with_check&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
        &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt; &lt;span class="o"&gt;!~*&lt;/span&gt; &lt;span class="s1"&gt;'auth&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s1"&gt;uid&lt;/span&gt;&lt;span class="se"&gt;\(\)&lt;/span&gt;&lt;span class="s1"&gt;[^=]*=|=[^=]*auth&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s1"&gt;uid&lt;/span&gt;&lt;span class="se"&gt;\(\)&lt;/span&gt;&lt;span class="s1"&gt;'&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;Run it in the Supabase SQL editor. On our test database, it returned the &lt;code&gt;auth.uid() is not null&lt;/code&gt; select policy and a &lt;code&gt;with check (true)&lt;/code&gt; insert policy, and 0 rows after the fix below. Postgres stores &lt;code&gt;(select auth.uid())&lt;/code&gt; as &lt;code&gt;( SELECT auth.uid() AS uid)&lt;/code&gt;; the pattern allows for that.&lt;/p&gt;

&lt;p&gt;Treat the results as a review list, not a verdict. It's a text match, so:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Expected hits:&lt;/strong&gt; genuinely public tables, and team tables scoped through an &lt;code&gt;exists (...)&lt;/code&gt; membership check. Check each one and make sure it's intentional.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What it can't see:&lt;/strong&gt; an &lt;code&gt;=&lt;/code&gt; that compares &lt;code&gt;auth.uid()&lt;/code&gt; to the &lt;em&gt;wrong&lt;/em&gt; column will pass. So will a policy that joins through an unfiltered table. The regex also passes any expression that contains a comparison somewhere, even when something else makes it broad. Both of these return 0 rows but scope nothing:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;anything&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&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;cekuu35's comment includes their own version of the regex and a runnable red/green demo. The extra step we'd add is a two-user test: sign in as user A and try to read and update user B's row. That catches the shapes a pattern match won't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: scope USING and WITH CHECK to the owner
&lt;/h2&gt;

&lt;p&gt;Replace the not-null test with a comparison against the owner column, one policy per operation the app needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;drop&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Users can view profiles"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Owners read own profile"&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Owners insert own profile"&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;
  &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Owners update own profile"&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;
  &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Owners delete own profile"&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt;
  &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leave out the delete policy if users shouldn't delete their own rows; with RLS on, no policy means no deletes.&lt;/p&gt;

&lt;p&gt;Some notes from the Supabase docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;using&lt;/code&gt; vs &lt;code&gt;with check&lt;/code&gt;:&lt;/strong&gt; &lt;code&gt;using&lt;/code&gt; decides which existing rows a user can see or target. &lt;code&gt;with check&lt;/code&gt; decides what a new or updated row is allowed to look like. On updates, you need both: without &lt;code&gt;with check&lt;/code&gt;, a user could update their row and set &lt;code&gt;user_id&lt;/code&gt; to someone else's. In our test, that reassignment failed with this error, which is what you want:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;new row violates row-level security policy for table "profiles"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;(select ...)&lt;/code&gt; wrapper:&lt;/strong&gt; wrapping &lt;code&gt;auth.uid()&lt;/code&gt; in &lt;code&gt;(select ...)&lt;/code&gt; lets Postgres evaluate it once per statement instead of once per row. It's a performance detail, not a security one, but it costs nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;to authenticated&lt;/code&gt;:&lt;/strong&gt; this keeps the anon role out entirely. Signed-out requests then can't match the policy at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you hit that same error on inserts after tightening policies, a common cause is &lt;code&gt;.insert().select()&lt;/code&gt; (or an upsert): returning the new row also needs a matching select policy, so keep the select policy alongside the insert one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Free credits and Gmail aliases: a new account isn't a new person
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/elijahbrown/comment/3hmo9"&gt;elijahbrown raised the second pattern&lt;/a&gt; in the same thread. Treating "account just created" as proof someone should get free credits doesn't hold up. Anyone can sign up again with &lt;code&gt;jane.doe+2@gmail.com&lt;/code&gt; or &lt;code&gt;j.a.n.e.doe@gmail.com&lt;/code&gt; and look brand new. Gmail ignores dots in the local part and everything after a &lt;code&gt;+&lt;/code&gt;, so all of those reach the same inbox.&lt;/p&gt;

&lt;p&gt;This isn't an RLS bug, strictly speaking. It's the same mistake, though: trusting something that looks like identity but isn't. Their suggested fix is to key the credit grant on a &lt;strong&gt;normalised&lt;/strong&gt; email in a separate claims table with a unique index, and only grant the credits when that insert succeeds.&lt;/p&gt;

&lt;p&gt;Here's a normalisation function. It lowercases every address. For &lt;code&gt;gmail.com&lt;/code&gt; and &lt;code&gt;googlemail.com&lt;/code&gt; it also strips dots and any &lt;code&gt;+suffix&lt;/code&gt; from the local part, and maps both domains to &lt;code&gt;gmail.com&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;replace&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;normalize_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;addr&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt;
&lt;span class="k"&gt;immutable&lt;/span&gt;
&lt;span class="k"&gt;strict&lt;/span&gt;
&lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;case&lt;/span&gt;
    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="n"&gt;split_part&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="s1"&gt;'@'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'gmail.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'googlemail.com'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="k"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;split_part&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;split_part&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="s1"&gt;'@'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'+'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
           &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'@gmail.com'&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's marked &lt;code&gt;immutable&lt;/code&gt; so it can feed a stored generated column; Postgres only allows immutable functions in &lt;a href="https://www.postgresql.org/docs/current/ddl-generated-columns.html" rel="noopener noreferrer"&gt;generated columns&lt;/a&gt;. Then the claims table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trial_claims&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;               &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;generated&lt;/span&gt; &lt;span class="n"&gt;always&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;identity&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;          &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;email&lt;/span&gt;            &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;email_normalized&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;generated&lt;/span&gt; &lt;span class="n"&gt;always&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;normalize_email&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="n"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;claimed_at&lt;/span&gt;       &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;trial_claims_email_normalized_key&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trial_claims&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email_normalized&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trial_claims&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- No policies for anon/authenticated: only your server code writes here.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's deliberately no &lt;code&gt;on delete cascade&lt;/code&gt;: if deleting an account also deleted its claim, the same inbox could delete, sign up again, and claim a second time. With &lt;code&gt;on delete set null&lt;/code&gt;, the claim row (and its normalised email) stays after the account is gone. The &lt;code&gt;unique&lt;/code&gt; on &lt;code&gt;user_id&lt;/code&gt; also stops one account claiming twice.&lt;/p&gt;

&lt;p&gt;Grant credits only when the claim row is actually inserted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;trial_claims&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&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="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;conflict&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="k"&gt;nothing&lt;/span&gt;
&lt;span class="n"&gt;returning&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the &lt;code&gt;returning&lt;/code&gt; comes back empty, someone already claimed with that inbox, or this account already claimed. (&lt;code&gt;on conflict do nothing&lt;/code&gt; with no target covers both unique checks.) In our test, &lt;code&gt;Jane.Doe+x@GoogleMail.com&lt;/code&gt; and &lt;code&gt;j.a.n.e.doe@gmail.com&lt;/code&gt; both normalised to &lt;code&gt;janedoe@gmail.com&lt;/code&gt;, and the second claim inserted nothing. Because the unique index does the check, two signups racing each other can't both win.&lt;/p&gt;

&lt;p&gt;Run the insert from server code, such as a Route Handler, Server Action, or webhook, using the email from the verified session, not one sent by the browser.&lt;/p&gt;

&lt;p&gt;Limits worth being honest about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gmail is easy; other domains aren't.&lt;/strong&gt; Some other providers support plus addressing too, and you could extend the function, but rules vary by provider. Lowercasing everything else is the safe default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disposable email domains get past this entirely.&lt;/strong&gt; Keep the free credit small, or put larger grants behind a verified payment method.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store the original email too.&lt;/strong&gt; Use the normalised one only for the uniqueness check, never as the address you send to.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A quick checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Run the &lt;code&gt;pg_policies&lt;/code&gt; query above and review every row it returns.&lt;/li&gt;
&lt;li&gt;Every per-user table: an owner comparison in &lt;code&gt;using&lt;/code&gt; on select, update, and delete, and in &lt;code&gt;with check&lt;/code&gt; on insert and update.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;to authenticated&lt;/code&gt; on policies that should never apply to signed-out users.&lt;/li&gt;
&lt;li&gt;A two-user test in CI: user A can't read or update user B's row.&lt;/li&gt;
&lt;li&gt;Free credits keyed on a normalised email with a unique index, granted only when the insert succeeds.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If you see &lt;code&gt;permission denied for table&lt;/code&gt; instead, that's a missing grant, not RLS. Supabase's changelog scheduled a Data API grants change for 30 Oct. In late August a Supabase CLI maintainer wrote (&lt;a href="https://github.com/supabase/cli/pull/6337" rel="noopener noreferrer"&gt;supabase/cli#6337&lt;/a&gt;) that the earlier default change "never landed" and hosted projects were still auto-granting, and the CLI dropped its own 30 Oct messaging. So the date may move: check what your own project does. More in &lt;a href="https://edtrackai.github.io/blog/supabase-permission-denied-for-table-data-api-grants" rel="noopener noreferrer"&gt;our post on the grants change&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Our &lt;a href="https://edtrackai.github.io/checker" rel="noopener noreferrer"&gt;free checker&lt;/a&gt; is MIT-licensed and runs locally. It flags tables without RLS, &lt;code&gt;using (true)&lt;/code&gt; / &lt;code&gt;with check (true)&lt;/code&gt; write policies, anon write policies, and missing grants in your migration files. It's heuristic and won't catch every policy shape above, so it's a first pass, not proof.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks to &lt;a href="https://dev.to/cekuu35"&gt;cekuu35&lt;/a&gt; and &lt;a href="https://dev.to/elijahbrown"&gt;elijahbrown&lt;/a&gt; for raising these in the &lt;a href="https://dev.to/edtrackai/7-security-bugs-ai-agents-write-in-nextjs-supabase-stripe-apps-and-the-rules-that-stop-them-3o64#comments"&gt;comment thread&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources (checked 15 October 2026):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://supabase.com/docs/guides/database/postgres/row-level-security" rel="noopener noreferrer"&gt;Supabase: Row Level Security&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/view-pg-policies.html" rel="noopener noreferrer"&gt;PostgreSQL: pg_policies&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/ddl-generated-columns.html" rel="noopener noreferrer"&gt;PostgreSQL: Generated columns&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Dev.to comments by &lt;a href="https://dev.to/cekuu35/comment/3hmng"&gt;cekuu35&lt;/a&gt; and &lt;a href="https://dev.to/elijahbrown/comment/3hmo9"&gt;elijahbrown&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Disclosure: we make AI Rules v2 (paid).&lt;/em&gt; It is a rules pack for Claude Code that steers the agent towards owner-scoped RLS policies like the ones above. It has 18 skills, 6 subagents, and guardrail hooks with automated tests. It doesn't replace code review or a two-user test. It costs $19 (plus VAT where applicable). Code &lt;strong&gt;DEVTO25&lt;/strong&gt; takes 30% off for DEV readers until 28 Oct, 11:00 AM PT: &lt;a href="https://edtrackai.github.io/ai-rules?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=e39-rls" rel="noopener noreferrer"&gt;edtrackai.github.io/ai-rules&lt;/a&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>28 launch checks for AI-built Next.js + Supabase + Stripe apps</title>
      <dc:creator>Stack &amp; Ship</dc:creator>
      <pubDate>Thu, 15 Oct 2026 08:08:00 +0000</pubDate>
      <link>https://dev.to/edtrackai/28-launch-checks-for-ai-built-nextjs-supabase-stripe-apps-4lk8</link>
      <guid>https://dev.to/edtrackai/28-launch-checks-for-ai-built-nextjs-supabase-stripe-apps-4lk8</guid>
      <description>&lt;p&gt;We build Next.js + Supabase + Stripe apps with AI coding agents. The agents are fast, and they make the same handful of mistakes over and over. This is the list we run before anything goes live. It has 28 checks in five groups: keys, auth, row level security, Stripe, and hardening. Each one comes with what to grep for and what the fix looks like.&lt;/p&gt;

&lt;p&gt;You don't need any tool for this. Most checks are a search plus five minutes of reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Keys and secrets (K1–K5)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;K1: Live secrets in source (critical).&lt;/strong&gt; Search for &lt;code&gt;sk_live_&lt;/code&gt;, &lt;code&gt;rk_live_&lt;/code&gt;, &lt;code&gt;sb_secret_&lt;/code&gt;, and service-role JWTs. If you find one, rotate it. Deleting the line doesn't help, because it's still in git history.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s1"&gt;'sk_live_|rk_live_|sb_secret_'&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;git rev-list &lt;span class="nt"&gt;--all&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;K2: Server secrets in a &lt;code&gt;"use client"&lt;/code&gt; file (critical).&lt;/strong&gt; That covers the admin client, the Stripe SDK, and any secret env var. Anything in a client file ships to every browser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;K3: &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; on a secret (critical).&lt;/strong&gt; &lt;code&gt;NEXT_PUBLIC_*SECRET&lt;/code&gt;, &lt;code&gt;*SERVICE_ROLE&lt;/code&gt;, &lt;code&gt;*PRIVATE&lt;/code&gt; and &lt;code&gt;*WEBHOOK*&lt;/code&gt; values all get inlined into the client bundle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;K4: &lt;code&gt;.env*&lt;/code&gt; files with values tracked by git (high).&lt;/strong&gt; Commit a &lt;code&gt;.env.example&lt;/code&gt; with empty values and nothing else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;K5: A &lt;code&gt;lib/&lt;/code&gt; module that reads a secret without &lt;code&gt;server-only&lt;/code&gt; (medium).&lt;/strong&gt; One accidental client import is enough to leak it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/stripe.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;server-only&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Auth (A1–A5)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A1: &lt;code&gt;auth.getSession()&lt;/code&gt; in server code (high).&lt;/strong&gt; It reads the cookie without verifying it, so it can be spoofed. On the server, use &lt;code&gt;getUser()&lt;/code&gt; (or &lt;code&gt;getClaims()&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;"&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;A2: Server actions that never check the user (high).&lt;/strong&gt; Every &lt;code&gt;"use server"&lt;/code&gt; export is a public POST endpoint, whether or not your UI shows a button for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A3: &lt;code&gt;redirect()&lt;/code&gt; to an unvalidated query param (high).&lt;/strong&gt; That's an open redirect, and it lets someone phish through your domain. Allow only same-site relative paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;safeNext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;//&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;next&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;&lt;strong&gt;A4: The admin (service-role) client outside webhooks, cron, or billing code (high).&lt;/strong&gt; It bypasses RLS. If user input picks the row, you have an IDOR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A5: Role or plan read from &lt;code&gt;user_metadata&lt;/code&gt; (medium).&lt;/strong&gt; Users can edit their own metadata. Keep roles and plans in a table that users can't write to.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Row level security (R1–R7)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;R1: A table in &lt;code&gt;public&lt;/code&gt; without RLS (critical).&lt;/strong&gt; Without it, anyone holding the public key can read and write the table.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="p"&gt;(...);&lt;/span&gt;
&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&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;R2: &lt;code&gt;using (true)&lt;/code&gt; or &lt;code&gt;with check (true)&lt;/code&gt; (critical).&lt;/strong&gt; This means everyone sees or writes every row. Agents love writing it to make an error go away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;R3: Write policies &lt;code&gt;to anon&lt;/code&gt; or &lt;code&gt;to public&lt;/code&gt; (high).&lt;/strong&gt; These allow signed-out writes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;R4: Insert or update policies without &lt;code&gt;with check&lt;/code&gt; (high).&lt;/strong&gt; Without it, users can write rows they don't own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"own notes update"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&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;R5: A &lt;code&gt;security definer&lt;/code&gt; function without &lt;code&gt;set search_path&lt;/code&gt; (high).&lt;/strong&gt; That opens a privilege-escalation path through search_path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;do_thing&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="k"&gt;definer&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="err"&gt;$$&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;R6: A view without &lt;code&gt;security_invoker = true&lt;/code&gt; (medium).&lt;/strong&gt; By default, views bypass RLS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;R7: &lt;code&gt;auth.uid()&lt;/code&gt; not wrapped in &lt;code&gt;(select ...)&lt;/code&gt; (low).&lt;/strong&gt; This one is about performance, not security. Unwrapped, it's evaluated per row, and that hurts on large tables.&lt;/p&gt;

&lt;h3&gt;
  
  
  A side note on Supabase grants
&lt;/h3&gt;

&lt;p&gt;This isn't one of the 28 checks, but it's worth knowing. Supabase's changelog schedules a change to default Data API grants for 30 Oct (see Supabase CLI PR #6337). If it goes ahead as scheduled, newly created tables may need explicit &lt;code&gt;grant&lt;/code&gt; statements before the Data API can reach them. Check your own project's settings and the changelog, and don't assume either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Stripe (S1–S7)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;S1: A webhook without &lt;code&gt;constructEvent&lt;/code&gt; (critical).&lt;/strong&gt; Without it, anyone can POST a fake "paid" event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S2: &lt;code&gt;request.json()&lt;/code&gt; before verification (critical).&lt;/strong&gt; Parsing the body breaks the signature. Read the raw text instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nodejs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// S6&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bad signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&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;S3: No idempotency on event ids (high).&lt;/strong&gt; Stripe retries. Store &lt;code&gt;event.id&lt;/code&gt; with a &lt;code&gt;pending&lt;/code&gt; or &lt;code&gt;completed&lt;/code&gt; status so a retry can't double-grant and a crashed event isn't skipped forever. If you reprocess &lt;code&gt;pending&lt;/code&gt; events, the handlers need to be idempotent upserts keyed on Stripe ids, or claimed with an atomic lease. Side effects like emails or credit grants need their own dedupe key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;stripe_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'completed'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&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;&lt;strong&gt;S4: Price or amount taken from the request (critical).&lt;/strong&gt; That lets users pick their own price. Look up the price on the server from a key you control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S5: Access granted on the success page (high).&lt;/strong&gt; The success page doesn't confirm payment, and it can be replayed. Grant access from the webhook.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S6: A webhook on the edge runtime (medium).&lt;/strong&gt; The signature code expects Node crypto. Set &lt;code&gt;runtime = "nodejs"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;S7: Billing period read from &lt;code&gt;items.data[0]&lt;/code&gt; (low).&lt;/strong&gt; Since API version 2025-03-31.basil, periods live on subscription items. With more than one item, &lt;code&gt;items.data[0]&lt;/code&gt; gives you the wrong period or price.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Hardening (H1–H4)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;H1: Route or action input with no schema validation (medium).&lt;/strong&gt; It opens you to type confusion and mass assignment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bad input&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&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;H2: Raw &lt;code&gt;error.message&lt;/code&gt; returned to the client (medium).&lt;/strong&gt; It leaks SQL, schema, and internal details. Log the error on the server and return a generic message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;H3: No security headers in &lt;code&gt;next.config&lt;/code&gt; (low).&lt;/strong&gt; This leaves you open to clickjacking and MIME sniffing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/(.*)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Frame-Options&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DENY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Content-Type-Options&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nosniff&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Referrer-Policy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;strict-origin-when-cross-origin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&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;&lt;strong&gt;H4: &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt; (medium).&lt;/strong&gt; If any of that content is user-controlled, it's an XSS hole. Sanitize it or render it as text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things grep won't find
&lt;/h2&gt;

&lt;p&gt;Read the code for these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;IDOR: for every route that takes an id, write out the exact request that would read someone else's row.&lt;/li&gt;
&lt;li&gt;Admin-client queries where user input picks rows or columns.&lt;/li&gt;
&lt;li&gt;Updates and deletes that filter only by id.&lt;/li&gt;
&lt;li&gt;Webhooks that identify the user by email alone. Use &lt;code&gt;client_reference_id&lt;/code&gt;, metadata set on the server, or your customers table instead.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;past_due&lt;/code&gt;, &lt;code&gt;canceled&lt;/code&gt;, or &lt;code&gt;incomplete&lt;/code&gt; statuses that still grant full access.&lt;/li&gt;
&lt;li&gt;Public storage buckets you didn't mean to make public.&lt;/li&gt;
&lt;li&gt;Unrate-limited custom auth endpoints.&lt;/li&gt;
&lt;li&gt;Secrets that get logged.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;npm audit --omit=dev&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then check the dashboards. Make sure the Supabase Security Advisor is clean, the auth redirect URLs point to production, the Stripe live webhook secret is separate from the test one, and backups are on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AI agents keep writing these
&lt;/h2&gt;

&lt;p&gt;Most of these come from the agent fixing the error in front of it. "Permission denied" turns into &lt;code&gt;using (true)&lt;/code&gt;. A signature error turns into dropping verification. "Not authorized" turns into reaching for the service-role client. Each change makes the error go away, and none of them looks wrong in a diff.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: we make AI Rules v2 (paid).&lt;/em&gt; It is a rules pack for Claude Code that steers the agent away from these patterns while it writes code. It has 18 skills, 6 subagents, and guardrail hooks with automated tests. It doesn't replace code review or a two-user test. It costs $19 (plus VAT where applicable). Code &lt;strong&gt;DEVTO25&lt;/strong&gt; takes 30% off for DEV readers until 28 Oct, 11:00 AM PT: &lt;a href="https://edtrackai.github.io/ai-rules?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=e37-launch-checks" rel="noopener noreferrer"&gt;edtrackai.github.io/ai-rules&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;More on our blog: &lt;a href="https://edtrackai.github.io/blog/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=f2-28checks" rel="noopener noreferrer"&gt;Next.js, Supabase and Stripe fix guides&lt;/a&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>supabase</category>
      <category>security</category>
      <category>ai</category>
    </item>
    <item>
      <title>How to Track MRR and Monthly Profit per Product in Notion (Free Template)</title>
      <dc:creator>Stack &amp; Ship</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:53:38 +0000</pubDate>
      <link>https://dev.to/edtrackai/how-to-track-mrr-and-monthly-profit-per-product-in-notion-free-template-4ihb</link>
      <guid>https://dev.to/edtrackai/how-to-track-mrr-and-monthly-profit-per-product-in-notion-free-template-4ihb</guid>
      <description>&lt;p&gt;If you run more than one product, a single revenue total hides what's actually working. One app might be paying for everything while another quietly costs you £40 a month in hosting, and the combined number looks fine either way.&lt;/p&gt;

&lt;p&gt;This guide shows how to calculate MRR and monthly profit &lt;strong&gt;per product&lt;/strong&gt; in Notion using relations, formulas and rollups. Everything here works on Notion's free plan, and every formula is copy-paste ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  The structure
&lt;/h2&gt;

&lt;p&gt;You need four databases, each linked to a central Products database:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;Key properties&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Products&lt;/td&gt;
&lt;td&gt;Name, Status, rollups (added in step 4)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revenue&lt;/td&gt;
&lt;td&gt;Date, Kind (Recurring / One-off), Gross, VAT, Fees, Product (relation)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expenses&lt;/td&gt;
&lt;td&gt;Date, Amount, Category, Product (relation)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subscriptions&lt;/td&gt;
&lt;td&gt;Cost, Billing (Monthly / Yearly), Status, Next renewal, Product (relation)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Products sits in the middle. Revenue, Expenses and Subscriptions each point at it, and Products pulls their numbers back with rollups:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Revenue        ──┐
Expenses       ──┼──► Products (MRR, revenue, costs, monthly profit)
Subscriptions  ──┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: Create Products
&lt;/h2&gt;

&lt;p&gt;One row per product, side project or client. Add a &lt;strong&gt;Status&lt;/strong&gt; select with Idea, Building, Launched and Paused.&lt;/p&gt;

&lt;p&gt;That's all for now. The useful columns come from rollups later, once the other databases exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Revenue with net and MRR formulas
&lt;/h2&gt;

&lt;p&gt;Create the Revenue database with Date, Kind (a select: Recurring / One-off), Gross, VAT and Fees (numbers). Add a &lt;strong&gt;Product&lt;/strong&gt; relation to Products and make it two-way, so Products gets a Revenue column.&lt;/p&gt;

&lt;p&gt;Then add these formula properties:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight datastudio"&gt;&lt;code&gt;&lt;span class="n"&gt;Net&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Gross"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"VAT"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Fees"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;MRR&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Kind"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"Recurring"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Gross"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"VAT"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;MRR&lt;/span&gt; &lt;span class="n"&gt;this&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Date"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s2"&gt;"YYYY-MM"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s2"&gt;"YYYY-MM"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"MRR"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;Net&lt;/span&gt; &lt;span class="n"&gt;this&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Date"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s2"&gt;"YYYY-MM"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s2"&gt;"YYYY-MM"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Net"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few notes on what these do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Net&lt;/strong&gt; is what actually lands with you: gross minus VAT minus payment or platform fees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MRR&lt;/strong&gt; only counts rows marked Recurring, so one-off sales don't inflate it.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;this month&lt;/strong&gt; formulas compare the row's year-month with today's. Anything outside the current calendar month returns 0, which is what makes the rollups in step 4 show "this month" without any filtering.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why exclude VAT from MRR?&lt;/strong&gt; If you sell through a merchant of record such as Gumroad, Paddle or Lemon Squeezy, the VAT was never yours. Including it inflates your numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Expenses and subscriptions
&lt;/h2&gt;

&lt;p&gt;These are two different kinds of cost, so keep them in two databases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expenses&lt;/strong&gt; are one-off spends (a domain, an icon pack, an ad test). Add a &lt;strong&gt;Product&lt;/strong&gt; relation, then a &lt;strong&gt;Spend this month&lt;/strong&gt; formula using the same month comparison as in step 2, applied to Amount:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight datastudio"&gt;&lt;code&gt;&lt;span class="n"&gt;Spend&lt;/span&gt; &lt;span class="n"&gt;this&lt;/span&gt; &lt;span class="k"&gt;month&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;if&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Date"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s2"&gt;"YYYY-MM"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;formatDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;today&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s2"&gt;"YYYY-MM"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Amount"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="m"&gt;0&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;Subscriptions&lt;/strong&gt; are the recurring tools: hosting, AI assistants, analytics, email. Add a &lt;strong&gt;Product&lt;/strong&gt; relation, then normalise everything to a monthly figure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cost per month = if(prop("Status") == "Cancelled", 0,
  if(prop("Billing") == "Yearly", round(prop("Cost") / 12 * 100) / 100, prop("Cost")))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yearly plans get divided by 12 (rounded to two decimals), and cancelled plans count as 0 but stay on record, so you keep the history without it skewing this month's costs.&lt;/p&gt;

&lt;p&gt;If your subscriptions list is a mess right now, I wrote a separate walkthrough on &lt;a href="https://edtrackai.github.io/blog/subscription-audit-indie-developers?utm_source=devto&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=notion-mrr" rel="noopener noreferrer"&gt;auditing indie tool spend&lt;/a&gt; that covers finding every subscription and adding a renewal countdown.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Roll it up on Products
&lt;/h2&gt;

&lt;p&gt;Back on Products, add four rollups, all set to &lt;strong&gt;Sum&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rollup on Products&lt;/th&gt;
&lt;th&gt;Relation&lt;/th&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MRR&lt;/td&gt;
&lt;td&gt;Revenue&lt;/td&gt;
&lt;td&gt;MRR this month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revenue this month&lt;/td&gt;
&lt;td&gt;Revenue&lt;/td&gt;
&lt;td&gt;Net this month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expenses this month&lt;/td&gt;
&lt;td&gt;Expenses&lt;/td&gt;
&lt;td&gt;Spend this month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool costs / month&lt;/td&gt;
&lt;td&gt;Subscriptions&lt;/td&gt;
&lt;td&gt;Cost per month&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then one formula:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Monthly profit = prop("Revenue this month") - prop("Expenses this month") - prop("Tool costs / month")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every product row shows its own MRR, revenue, costs and profit for the current month.&lt;/p&gt;

&lt;p&gt;Turn on the column &lt;strong&gt;Sum&lt;/strong&gt; at the bottom of a table view to get business-wide totals, so you get both views: the whole business and each product.&lt;/p&gt;

&lt;p&gt;Two views worth adding on Products:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;This month&lt;/strong&gt;: a table showing MRR, Revenue this month, Expenses this month, Tool costs / month and Monthly profit, sorted by Monthly profit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Losing money&lt;/strong&gt;: the same table filtered to Monthly profit less than 0. This is the one that changes decisions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limitations to know
&lt;/h2&gt;

&lt;p&gt;This setup is genuinely useful, but be honest with yourself about what it isn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No currency conversion.&lt;/strong&gt; Notion adds up numbers as entered. Pick a home currency and convert before entering; keep a Currency tag for reference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual entry.&lt;/strong&gt; Notion doesn't pull from Stripe or Gumroad natively. Log weekly, or use Zapier/Make.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"This month" is calendar-based.&lt;/strong&gt; Annual plans paid upfront show as a spike unless you enter them as monthly instalments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's tracking, not accounting.&lt;/strong&gt; Your accountant still needs proper records.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This isn't tax or accounting advice; check what your own situation needs.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I make &lt;a href="https://edtrackai.github.io/solo-founder-os?utm_source=devto&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=notion-mrr" rel="noopener noreferrer"&gt;Solo Founder OS&lt;/a&gt;, which has all of the above pre-built alongside tasks, releases and a dashboard. There's also a free Lite version covering projects, tasks and subscriptions with renewal reminders. Originally published on &lt;a href="https://edtrackai.github.io/blog/notion-mrr-profit-tracker?utm_source=devto&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=notion-mrr-canonical" rel="noopener noreferrer"&gt;my blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>notion</category>
      <category>indiehackers</category>
      <category>productivity</category>
      <category>saas</category>
    </item>
    <item>
      <title>7 security bugs AI agents write in Next.js + Supabase + Stripe apps (and the rules that stop them)</title>
      <dc:creator>Stack &amp; Ship</dc:creator>
      <pubDate>Wed, 14 Oct 2026 01:54:31 +0000</pubDate>
      <link>https://dev.to/edtrackai/7-security-bugs-ai-agents-write-in-nextjs-supabase-stripe-apps-and-the-rules-that-stop-them-3o64</link>
      <guid>https://dev.to/edtrackai/7-security-bugs-ai-agents-write-in-nextjs-supabase-stripe-apps-and-the-rules-that-stop-them-3o64</guid>
      <description>&lt;p&gt;AI coding agents are great at getting a Next.js + Supabase + Stripe app &lt;em&gt;working&lt;/em&gt;. They're much less reliable at getting it &lt;em&gt;safe&lt;/em&gt;. The bugs below all pass a quick click-through test, which is exactly why they ship.&lt;/p&gt;

&lt;p&gt;Every "fixed" snippet here is taken from a small reference app I built with Next.js 16, &lt;code&gt;@supabase/ssr&lt;/code&gt; and the Stripe Node SDK, so you can copy them with some confidence. At the end there's a short rule you can drop into &lt;code&gt;AGENTS.md&lt;/code&gt; / &lt;code&gt;.cursor/rules&lt;/code&gt; for each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. RLS "enabled" but a policy that protects nothing
&lt;/h2&gt;

&lt;p&gt;Agents love to make an empty query go away by writing this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- ❌ passes the "RLS enabled" check, protects nothing&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"allow all"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subscriptions&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&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;What you want is ownership scoped to the signed-in user, and no write policy at all for tables only your server should write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subscriptions&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"read own subscriptions"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subscriptions&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrapping &lt;code&gt;auth.uid()&lt;/code&gt; in &lt;code&gt;(select ...)&lt;/code&gt; lets Postgres evaluate it once per query instead of once per row. Find existing offenders with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&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;Rule:&lt;/strong&gt; &lt;em&gt;Every table in &lt;code&gt;public&lt;/code&gt; has RLS enabled. Never write &lt;code&gt;using (true)&lt;/code&gt;. If a query returns no rows, fix the policy — never switch keys.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. "Fixing" an empty result with the service-role key
&lt;/h2&gt;

&lt;p&gt;The second half of bug #1. When RLS correctly returns nothing, agents often swap in the secret/service-role key, which bypasses RLS completely. Keep two clearly named clients and make the dangerous one obvious:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Per-request client acting as the signed-in user. RLS applies. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createUserClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* createServerClient(url, PUBLISHABLE_KEY, { cookies }) */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/** Admin client that BYPASSES RLS. Only for trusted server code such as webhooks. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createAdminClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;env&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;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_SUPABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;persistSession&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;autoRefreshToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&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;Put &lt;code&gt;import "server-only";&lt;/code&gt; at the top of that file so the build fails if a client component ever imports it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; &lt;em&gt;The admin client is only used in webhook handlers and background jobs, never in code that runs on behalf of a user.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Trusting &lt;code&gt;getSession()&lt;/code&gt; on the server
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;supabase.auth.getSession()&lt;/code&gt; reads the cookie without re-validating it with Supabase Auth. On the server, check the user properly before doing anything that matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createUserClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&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="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not signed in&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;getClaims()&lt;/code&gt; is also fine if you're on asymmetric JWT signing keys.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; &lt;em&gt;Server-side authorization uses &lt;code&gt;getUser()&lt;/code&gt;/&lt;code&gt;getClaims()&lt;/code&gt;, never &lt;code&gt;getSession()&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Parsing the Stripe webhook body before verifying it
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;await request.json()&lt;/code&gt; re-serialises the payload and the signature check fails. So the agent "fixes" it by deleting the check. Read the raw text instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nodejs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Missing signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Raw body is required for signature verification. Never parse JSON first.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also exclude the webhook path from any auth middleware/proxy so it isn't redirected to a login page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/((?!_next/static|_next/image|favicon.ico|api/webhooks).*)&lt;/span&gt;&lt;span class="dl"&gt;"&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;&lt;strong&gt;Rule:&lt;/strong&gt; &lt;em&gt;Webhook handlers read &lt;code&gt;request.text()&lt;/code&gt; and verify the signature before touching the payload.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Non-idempotent webhooks
&lt;/h2&gt;

&lt;p&gt;Stripe retries deliveries and can send events more than once. Record each event id with a primary key and bail on duplicates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stripe_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;received_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stripe_events&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- no policies = no client access&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dupError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe_events&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dupError&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;23505&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duplicate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dupError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DB error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning 500 on a real DB error matters too: it tells Stripe to retry later instead of silently losing the event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; &lt;em&gt;Every webhook is idempotent on &lt;code&gt;event.id&lt;/code&gt;; use &lt;code&gt;upsert&lt;/code&gt; for state.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Granting access from the success redirect
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/account?checkout=success&lt;/code&gt; is just a URL — anyone can visit it. Access must come from the webhook-mirrored subscription status, and the mapping should be explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;accessFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grace&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;trialing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;past_due&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grace&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&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;Link the Stripe customer back to your user at checkout creation (&lt;code&gt;client_reference_id: user.id&lt;/code&gt;) so the webhook knows whose subscription it is — never take a user id or price from the request body.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; &lt;em&gt;Paid access is derived only from webhook-written rows. Prices come from env/server config, never from the client.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Open redirects in auth callbacks
&lt;/h2&gt;

&lt;p&gt;Magic-link and OAuth callbacks usually accept a &lt;code&gt;?next=&lt;/code&gt; param. Agents pass it straight to &lt;code&gt;redirect()&lt;/code&gt;, which turns your login into a phishing redirector (&lt;code&gt;?next=//evil.com&lt;/code&gt;). Allow only same-origin relative paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;safeNext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/account&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;//&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;next&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;safeNext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login?error=link-invalid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&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;Rule:&lt;/strong&gt; &lt;em&gt;Any redirect target from user input goes through &lt;code&gt;safeNext()&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: fail loudly on bad env
&lt;/h2&gt;

&lt;p&gt;A missing &lt;code&gt;STRIPE_WEBHOOK_SECRET&lt;/code&gt; shouldn't become &lt;code&gt;undefined&lt;/code&gt; at runtime. Validate lazily (so &lt;code&gt;next build&lt;/code&gt; still works without secrets) with zod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;SUPABASE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sk_&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;whsec_&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;STRIPE_PRICE_ID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;price_&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&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;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;None of these are exotic. They're the predictable shortcuts an agent takes when its only goal is "make the error go away." Writing the rules down where the agent reads them every time (&lt;code&gt;AGENTS.md&lt;/code&gt;, &lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;.cursor/rules&lt;/code&gt;) fixes most of them before they're written.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I made these resources.&lt;/em&gt; I turned this into a &lt;strong&gt;free&lt;/strong&gt; 30-point &lt;a href="https://luxurydrive0.gumroad.com/l/security-checklist" rel="noopener noreferrer"&gt;Supabase + Stripe pre-launch security checklist&lt;/a&gt;. If you'd rather have the full rule files plus the reference app these snippets come from, it's a &lt;a href="https://luxurydrive0.gumroad.com/l/ffjsf?code=DEVTO25" rel="noopener noreferrer"&gt;$15 pack (25% off with DEVTO25)&lt;/a&gt;. Either way, I hope the list above saves you a bad launch day — corrections welcome in the comments.&lt;/p&gt;




&lt;p&gt;More on our blog: &lt;a href="https://edtrackai.github.io/blog/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=sprint1&amp;amp;utm_content=f1-7bugs" rel="noopener noreferrer"&gt;Supabase and Stripe security fixes&lt;/a&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>supabase</category>
      <category>security</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
