<?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: cometa-kaito</title>
    <description>The latest articles on DEV Community by cometa-kaito (@cometakaito).</description>
    <link>https://dev.to/cometakaito</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%2F4191440%2F4f7e179f-b8fa-446c-899e-22434cd183e3.png</url>
      <title>DEV Community: cometa-kaito</title>
      <link>https://dev.to/cometakaito</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cometakaito"/>
    <language>en</language>
    <item>
      <title>"An invalid form control is not focusable": Hidden Steps</title>
      <dc:creator>cometa-kaito</dc:creator>
      <pubDate>Sun, 18 Oct 2026 08:14:11 +0000</pubDate>
      <link>https://dev.to/cometakaito/an-invalid-form-control-is-not-focusable-hidden-steps-48ii</link>
      <guid>https://dev.to/cometakaito/an-invalid-form-control-is-not-focusable-hidden-steps-48ii</guid>
      <description>&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A three-step application form was built as a single &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt;, with each step shown or hidden using the &lt;code&gt;hidden&lt;/code&gt; attribute (equivalent to &lt;code&gt;display:none&lt;/code&gt;). The required fields live on step 1; the submit button lives on step 2. Step 1's "Continue to confirmation" button is &lt;code&gt;type="button"&lt;/code&gt; and validates nothing, so a user can reach step 2 with required fields still empty. Pressing the step 2 submit button (&lt;code&gt;type="submit"&lt;/code&gt;) then triggers the browser's native validation, which finds the empty required fields — but by then step 1's section is &lt;code&gt;hidden&lt;/code&gt; and unfocusable, so the browser has no way to surface an error and silently aborts the submit.&lt;/strong&gt; The fix: add &lt;code&gt;noValidate&lt;/code&gt; to the form to disable native validation, and run an explicit check inside "Continue to confirmation" that renders an error message on the spot instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;/apply&lt;/code&gt; form for signing up for the school-advertising support program has three steps — choose a school (step 0), enter listing details and contact info (step 1), and confirm and submit (step 2) — driven by a &lt;code&gt;step&lt;/code&gt; value in &lt;code&gt;useState&lt;/code&gt;. All three steps' inputs live inside a &lt;strong&gt;single &lt;code&gt;&amp;lt;form action={formAction}&amp;gt;&lt;/code&gt;&lt;/strong&gt;, and which step is visible is decided purely by wrapping each step in a &lt;code&gt;&amp;lt;section&amp;gt;&lt;/code&gt; with the &lt;code&gt;hidden&lt;/code&gt; attribute.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;formAction&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* STEP 0: choose school + period */&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;section&lt;/span&gt; &lt;span class="na"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;...&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* STEP 1: listing content (required contact fields live here) */&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;section&lt;/span&gt; &lt;span class="na"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    ...
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"sf_name"&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="na"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"sf_email"&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="na"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    ...
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&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;setStep&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;gt;&lt;/span&gt;Continue to confirmation&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* STEP 2: confirm and submit (the only submit button is here) */&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;section&lt;/span&gt; &lt;span class="na"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    ...
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Submit&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During a live production walkthrough by the author and Claude, we advanced to the step 2 confirmation screen without ever filling in the contact name or email, and pressing "Submit" produced &lt;strong&gt;no change on screen whatsoever&lt;/strong&gt; — no error message, no loading state, not even a visible reaction to the click. The only trace was a single warning in the browser devtools console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;An invalid form control with name='name' is not focusable.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Cause
&lt;/h2&gt;

&lt;p&gt;Moving between steps is implemented with a &lt;code&gt;type="button"&lt;/code&gt; that only mutates React state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&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;setStep&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;gt;&lt;/span&gt;
  Continue to confirmation
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clicking a &lt;code&gt;type="button"&lt;/code&gt; is not a form submission, so &lt;strong&gt;native browser input validation never runs here at all.&lt;/strong&gt; The required &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;email&lt;/code&gt; fields can be empty and the user still advances straight to the step 2 confirmation screen without any friction.&lt;/p&gt;

&lt;p&gt;The only &lt;code&gt;type="submit"&lt;/code&gt; button anywhere in the form is "Submit," on step 2.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="si"&gt;}&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;pending&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Submitting…&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;Submit&lt;/span&gt;&lt;span class="dl"&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;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The moment a user presses it, the browser runs native HTML5 validation against the entire &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt;. &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;email&lt;/code&gt; are &lt;code&gt;required&lt;/code&gt;, so an empty value is correctly flagged as invalid — but &lt;strong&gt;at this point &lt;code&gt;step&lt;/code&gt; is &lt;code&gt;2&lt;/code&gt;, and the step 1 section containing those fields has &lt;code&gt;hidden={step !== 1}&lt;/code&gt; evaluate to true, making it &lt;code&gt;display:none&lt;/code&gt;.&lt;/strong&gt; An element with &lt;code&gt;display:none&lt;/code&gt; cannot receive focus. The browser tries to focus the offending field to show the validation error to the user, fails, logs a warning to the console, and silently cancels the submission. No visible error, no scroll, nothing.&lt;/p&gt;

&lt;p&gt;In other words, what looked like "the user can just fill in the required field later" was actually a structure where &lt;strong&gt;the only way to reach that required field was already gone from the screen by the time submission was attempted.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;noValidate&lt;/code&gt; was added to the &lt;code&gt;form&lt;/code&gt; element, disabling native browser validation entirely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* noValidate: the input gate is goConfirm (which shows errors) plus server-side
    validation. Native validation silently blocks when a required field sits in a
    hidden section, which looks like a hang, so it's disabled. */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;formAction&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;noValidate&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In its place, step 1's "Continue to confirmation" button now calls &lt;code&gt;goConfirm&lt;/code&gt;, which runs an explicit check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;goConfirm&lt;/span&gt; &lt;span class="o"&gt;=&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="na"&gt;missing&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;creativeMode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;omakase&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;omBiz&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="nx"&gt;missing&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;business type&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;omMessage&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="nx"&gt;missing&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;key message&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;company&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="nx"&gt;missing&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;business/company name&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;contactName&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="nx"&gt;missing&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;contact name&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;email&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="nx"&gt;missing&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;email address&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;else&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;EMAIL_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&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="nx"&gt;missing&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;email address (check the format)&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;missing&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;&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setStepError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Missing fields: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;missing&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="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="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;setStepError&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="nf"&gt;setStep&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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If anything is missing, a &lt;code&gt;role="alert"&lt;/code&gt; message renders right there — on step 1, which is still visible — and the user is not advanced to step 2.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;stepError&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"alert"&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"mt-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"&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;stepError&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;p&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;This check can also cover fields the HTML5 &lt;code&gt;required&lt;/code&gt; attribute never saw, like the business type and key message that only apply when &lt;code&gt;omakase&lt;/code&gt; mode is selected. Native validation only looks at fields marked &lt;code&gt;required&lt;/code&gt;; &lt;code&gt;goConfirm&lt;/code&gt; can express business rules (these two fields are mandatory only in &lt;code&gt;omakase&lt;/code&gt; mode) directly in code. Final validation is still left to the server-side Server Action, unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prevention
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Packing multiple steps of input into a single &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; and toggling visibility with &lt;code&gt;hidden&lt;/code&gt; is fundamentally at odds with native HTML5 validation.&lt;/strong&gt; Browser input validation looks at the &lt;em&gt;whole form&lt;/em&gt; and hunts for "the first invalid field that can receive focus" — it has no concept of which step is currently displayed. The moment required fields and the submit button end up on different steps, it becomes possible for &lt;strong&gt;the field validation needs to reach right when submission is attempted to have already vanished from the screen.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To stay safe with this pattern, pick one of two approaches up front: (1) treat any multi-step form as &lt;code&gt;noValidate&lt;/code&gt; by default and write an explicit check on every step transition, or (2) split each step into its own separate &lt;code&gt;&amp;lt;form&amp;gt;&lt;/code&gt; so native validation stays self-contained within a single step. A form that "looks like it works but silently can't submit" is hard to catch with automated tests — this one only surfaced through a live, hands-on walkthrough in production.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tech.rebounder.jp/en/posts/multistep-form-hidden-required-field-blocks-submit-not-focusable/" rel="noopener noreferrer"&gt;tech.rebounder.jp&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>css</category>
      <category>javascript</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Supabase SECURITY DEFINER RPC Callable by anon: Revoke It</title>
      <dc:creator>cometa-kaito</dc:creator>
      <pubDate>Sun, 18 Oct 2026 04:22:31 +0000</pubDate>
      <link>https://dev.to/cometakaito/supabase-security-definer-rpc-callable-by-anon-revoke-it-5abe</link>
      <guid>https://dev.to/cometakaito/supabase-security-definer-rpc-callable-by-anon-revoke-it-5abe</guid>
      <description>&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;PostgreSQL grants EXECUTE to the &lt;code&gt;PUBLIC&lt;/code&gt; role by default the moment &lt;code&gt;CREATE FUNCTION&lt;/code&gt; runs.&lt;/strong&gt; &lt;code&gt;SECURITY DEFINER&lt;/code&gt; is the modifier that makes a function run with the privileges of the role that owns it rather than the role that called it. Create a write function with that modifier and leave it without an explicit &lt;code&gt;REVOKE&lt;/code&gt;, and it becomes directly callable from the &lt;code&gt;anon&lt;/code&gt; (unauthenticated) or &lt;code&gt;authenticated&lt;/code&gt; role through Supabase's PostgREST layer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Even with no &lt;code&gt;GRANT&lt;/code&gt; written anywhere, &lt;code&gt;EXECUTE&lt;/code&gt; for &lt;code&gt;PUBLIC&lt;/code&gt; is there from the start&lt;/li&gt;
&lt;li&gt;RLS policies on the table are irrelevant, because a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function runs as the owner&lt;/li&gt;
&lt;li&gt;Closing it means writing &lt;code&gt;REVOKE ALL ... FROM PUBLIC&lt;/code&gt; explicitly and re-&lt;code&gt;GRANT&lt;/code&gt;ing only to the roles that need it&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;

&lt;p&gt;We created a function to push records for asynchronous delivery into a table called &lt;code&gt;sync_outbox&lt;/code&gt;. Its definition pins &lt;code&gt;search_path&lt;/code&gt;. &lt;code&gt;search_path&lt;/code&gt; is the schema search path deciding which schema an unqualified object name resolves from inside a function, and pinning it to &lt;code&gt;public&lt;/code&gt; is standard practice to stop a caller rewriting it so that a fake same-named object outside &lt;code&gt;public&lt;/code&gt; resolves first.&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;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&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;sync_outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;            &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;kind&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;ref_id&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;payload&lt;/span&gt;       &lt;span class="n"&gt;jsonb&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="s1"&gt;'{}'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&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;default&lt;/span&gt; &lt;span class="s1"&gt;'pending'&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;'sent'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'failed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'dead'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;attempts&lt;/span&gt;      &lt;span class="nb"&gt;int&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="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;-- (omitted)&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;sync_outbox&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="n"&gt;sync_outbox_staff_read&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;sync_outbox&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;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_staff&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;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;enqueue_sync_outbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_kind&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_ref_id&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_payload&lt;/span&gt; &lt;span class="n"&gt;jsonb&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;uuid&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;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="k"&gt;public&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;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;sync_outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ref_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&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_kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p_ref_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p_payload&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="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="n"&gt;returning&lt;/span&gt; &lt;span class="n"&gt;id&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;The table's RLS says only "allow SELECT for the &lt;code&gt;staff&lt;/code&gt; role". There is no policy anywhere permitting &lt;code&gt;INSERT&lt;/code&gt; for &lt;code&gt;anon&lt;/code&gt;. The function was written on the premise that only the app's backend (a cron worker) calls it. Pinning &lt;code&gt;search_path&lt;/code&gt; was handled, but that guards a different kind of attack and does nothing for the problem here.&lt;/p&gt;

&lt;p&gt;But this function is &lt;code&gt;SECURITY DEFINER&lt;/code&gt;, and &lt;strong&gt;&lt;code&gt;EXECUTE&lt;/code&gt; for &lt;code&gt;PUBLIC&lt;/code&gt; is granted by default the instant &lt;code&gt;CREATE FUNCTION&lt;/code&gt; runs.&lt;/strong&gt; Supabase's PostgREST automatically exposes functions in the &lt;code&gt;public&lt;/code&gt; schema at &lt;code&gt;/rest/v1/rpc/&amp;lt;name&amp;gt;&lt;/code&gt;. It connects as &lt;code&gt;anon&lt;/code&gt; or &lt;code&gt;authenticated&lt;/code&gt; depending on the JWT the caller sends, and both roles inherit &lt;code&gt;PUBLIC&lt;/code&gt;'s privileges, so &lt;strong&gt;any function not explicitly revoked can be called directly by anyone.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is what the review flagged. The table's RLS correctly narrowed reads to &lt;code&gt;staff&lt;/code&gt;, and the function's execute privilege had never been narrowed at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;Three facts stack up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;① &lt;code&gt;SECURITY DEFINER&lt;/code&gt; runs with the function owner's privileges&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not the calling role's privileges but those of the role that owns the function. That much is as specified.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;② &lt;code&gt;CREATE FUNCTION&lt;/code&gt; grants &lt;code&gt;EXECUTE&lt;/code&gt; to &lt;code&gt;PUBLIC&lt;/code&gt; by default&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Even with no &lt;code&gt;GRANT&lt;/code&gt; ever written, the function is callable by anyone the moment it is created. This is the opposite of tables, whose &lt;code&gt;GRANT&lt;/code&gt; is closed by default (nobody can touch them without explicit permission): &lt;strong&gt;functions are open by default.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;③ PostgREST automatically exposes &lt;code&gt;public&lt;/code&gt; schema functions as RPCs&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;anon&lt;/code&gt; / &lt;code&gt;authenticated&lt;/code&gt; are the connection roles Supabase provides, and they inherit &lt;code&gt;PUBLIC&lt;/code&gt;'s privileges as they are. With not a single &lt;code&gt;GRANT&lt;/code&gt; on the table, one forgotten &lt;code&gt;REVOKE&lt;/code&gt; on a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function leaves a write path open that bypasses RLS entirely.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;table GRANT        → closed by default → nobody touches it without explicit permission
function EXECUTE   → open by default   → anyone calls it unless explicitly revoked
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This asymmetry is the cause of the oversight. &lt;strong&gt;Look only at the table and conclude "RLS protects this", and the hole on the function side goes unseen.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it
&lt;/h2&gt;

&lt;p&gt;Strip the default &lt;code&gt;PUBLIC&lt;/code&gt; privilege and re-allow only the roles that need it.&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;-- Close the SECURITY DEFINER write function to direct calls from anon/authenticated&lt;/span&gt;
&lt;span class="c1"&gt;-- (via PostgREST). Strip the default PUBLIC EXECUTE, allow only the backend (service_role).&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;enqueue_sync_outbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;text&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="n"&gt;jsonb&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="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;enqueue_sync_outbox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;text&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="n"&gt;jsonb&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;&lt;code&gt;REVOKE ALL ... FROM PUBLIC&lt;/code&gt; cuts off every inheritance source including &lt;code&gt;anon&lt;/code&gt; / &lt;code&gt;authenticated&lt;/code&gt;, and &lt;code&gt;GRANT EXECUTE&lt;/code&gt; returns it to &lt;code&gt;service_role&lt;/code&gt; alone, which actually needs to call it. The function definition itself is unchanged. &lt;strong&gt;All that happened is an open door was closed&lt;/strong&gt;, with no effect on the intended calling path from the backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking it
&lt;/h2&gt;

&lt;p&gt;Joining &lt;code&gt;pg_proc&lt;/code&gt; and &lt;code&gt;pg_roles&lt;/code&gt; gives you a function's owner and its execute privileges.&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rolname&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proacl&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_proc&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_roles&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proowner&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'enqueue_sync_outbox'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for &lt;code&gt;EXECUTE&lt;/code&gt; for &lt;code&gt;PUBLIC&lt;/code&gt; remaining in &lt;code&gt;proacl&lt;/code&gt; (notation like &lt;code&gt;=X/&amp;lt;owner&amp;gt;&lt;/code&gt;). Unless this check is run on every new &lt;code&gt;SECURITY DEFINER&lt;/code&gt; write function, the same path opens once per function whose &lt;code&gt;revoke&lt;/code&gt; was forgotten.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it not recur
&lt;/h2&gt;

&lt;p&gt;This fix went in as an addition of &lt;code&gt;revoke&lt;/code&gt; / &lt;code&gt;grant&lt;/code&gt; to the existing migration file rather than recreating the function. Unless &lt;strong&gt;writing &lt;code&gt;REVOKE ALL ... FROM PUBLIC&lt;/code&gt; paired with every &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function&lt;/strong&gt; becomes the rule, the next write function added opens the same path.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Write &lt;code&gt;REVOKE&lt;/code&gt; / &lt;code&gt;GRANT&lt;/code&gt; directly beneath the creation SQL for any &lt;code&gt;SECURITY DEFINER&lt;/code&gt; write function, so that adding only one of them is structurally impossible&lt;/li&gt;
&lt;li&gt;Include &lt;strong&gt;the function's &lt;code&gt;EXECUTE&lt;/code&gt; privilege in the same review item&lt;/strong&gt; as the table's RLS review (on the premise of the asymmetry: tables closed by default, functions open by default)&lt;/li&gt;
&lt;li&gt;Verify whether something is anonymously writable by actually querying &lt;code&gt;pg_proc.proacl&lt;/code&gt;, not by reading the wording of a policy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;However carefully the RLS policies are written, &lt;strong&gt;it means nothing if another door — the function's execute privilege — stands open.&lt;/strong&gt; Another case shaped as an overlooked &lt;code&gt;SECURITY DEFINER&lt;/code&gt; is in &lt;a href="https://tech.rebounder.jp/en/posts/security-definer-owner-role/" rel="noopener noreferrer"&gt;The function owner and the migration role&lt;/a&gt;. That one is about an intended bypass ceasing to work; this one about an unintended bypass working. Both start from the same property: &lt;strong&gt;&lt;code&gt;SECURITY DEFINER&lt;/code&gt; behaviour is decided by who created the function, not by who called it.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://tech.rebounder.jp/en/posts/supabase-security-definer-function-default-public-execute-exposes-rpc/" rel="noopener noreferrer"&gt;tech.rebounder.jp&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
