<?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: Muhammad Ali Mughal</title>
    <description>The latest articles on DEV Community by Muhammad Ali Mughal (@muhammadalimughal).</description>
    <link>https://dev.to/muhammadalimughal</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%2F4145544%2F4189fcb4-1949-496a-8c1a-7ec317ba6b06.jpg</url>
      <title>DEV Community: Muhammad Ali Mughal</title>
      <link>https://dev.to/muhammadalimughal</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/muhammadalimughal"/>
    <language>en</language>
    <item>
      <title>Rolling Out Passkeys Without Locking Users Out</title>
      <dc:creator>Muhammad Ali Mughal</dc:creator>
      <pubDate>Fri, 13 Nov 2026 16:12:23 +0000</pubDate>
      <link>https://dev.to/muhammadalimughal/rolling-out-passkeys-without-locking-users-out-o9d</link>
      <guid>https://dev.to/muhammadalimughal/rolling-out-passkeys-without-locking-users-out-o9d</guid>
      <description>&lt;p&gt;The first time I shipped passkeys for a client's SaaS dashboard, sign-in worked perfectly on staging and failed for about 30% of users on production for two days. Nothing was wrong with the cryptography. Production served the login page from &lt;code&gt;app.example.com&lt;/code&gt;, but the backend verified responses against an RP ID and origin configured as &lt;code&gt;example.com&lt;/code&gt; and &lt;code&gt;https://example.com&lt;/code&gt;. Chrome users on one path were fine; users who registered through the marketing site's embedded flow were not. Every failure looked the same in the logs: "verification failed". Passkeys are the best login upgrade most web apps can make right now, but the rollout details decide whether users love them or get locked out.&lt;/p&gt;

&lt;p&gt;I wrote this with help from AI tools and checked every snippet and config myself against SimpleWebAuthn v11+ and current Chrome, Safari and Firefox behaviour. I am a Principal Software Engineer in Karachi with 9+ years across PHP/Laravel, Vue, Node and .NET microservices, and I build the free developer tools at PixelValut. Everything here is about hardening authentication in an app you own.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;📚 Series Navigation — Passkeys &amp;amp; Session Hardening for Web Apps&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1 (This Post):&lt;/strong&gt; Rolling Out Passkeys Without Locking Users Out&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 2:&lt;/strong&gt; Session Lifecycle Hardening: Rotation, Timeouts &amp;amp; Server-Side Revocation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3:&lt;/strong&gt; CSRF in 2026: SameSite, Fetch Metadata &amp;amp; Origin Checks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 4:&lt;/strong&gt; Account Recovery Hardening: The Side Door Next to Your Passkey&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 5:&lt;/strong&gt; Protecting Login Endpoints: Credential-Stuffing Signals &amp;amp; Adaptive Rate Limits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 6:&lt;/strong&gt; Step-Up Authentication for Sensitive Actions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Related earlier posts:&lt;/em&gt; &lt;a href="https://dev.to/muhammadalimughal/browser-secrets-that-leak-token-storage-quotas-outbound-controls-cf2"&gt;Browser Secrets That Leak: Token Storage, Quotas &amp;amp; Outbound Controls&lt;/a&gt; · &lt;a href="https://dev.to/muhammadalimughal/oauth-21-oidc-id-tokens-pkce-forensics-auditing-claims-nonces-audience-confusion-in-spas-2eh9"&gt;OAuth 2.1, OIDC ID Tokens &amp;amp; PKCE Forensics&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How do you add passkeys to a web app without locking users out?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Direct answer:&lt;/strong&gt; Add passkeys alongside existing login, not instead of it. Fix your RP ID and allowed origins first, store each credential with its counter and transports, let users register more than one, and offer passkeys through browser autofill. Keep a tested recovery path, then measure sign-in success before making passkeys the default.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgw6qjh8dpbkxl1zpz135.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgw6qjh8dpbkxl1zpz135.jpg" alt="A heavy steel vault door standing slightly open with warm light inside, AI-generated" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What a passkey actually changes
&lt;/h2&gt;

&lt;p&gt;A passkey is a WebAuthn credential: a key pair where the private key stays in the user's authenticator (phone, laptop, password manager or hardware key) and your server stores only the public key. At login your server sends a random challenge, the authenticator signs it, and you verify the signature. The browser binds every response to the origin, which is why passkeys resist phishing: a lookalike domain cannot get a valid signature for your site.&lt;/p&gt;

&lt;p&gt;That also means configuration mistakes fail closed. The table below covers the options that matter most in a rollout:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Option A&lt;/th&gt;
&lt;th&gt;Option B&lt;/th&gt;
&lt;th&gt;What I default to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Passkey type&lt;/td&gt;
&lt;td&gt;Synced (iCloud Keychain, Google Password Manager, 1Password)&lt;/td&gt;
&lt;td&gt;Device-bound (hardware key)&lt;/td&gt;
&lt;td&gt;Accept both; synced for most users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discoverable credential&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;residentKey: 'required'&lt;/code&gt; (usernameless)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;'preferred'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;'preferred'&lt;/code&gt;, then autofill handles the UX&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User verification&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;'required'&lt;/code&gt; (biometric/PIN every time)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;'preferred'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;'preferred'&lt;/code&gt; for consumer, &lt;code&gt;'required'&lt;/code&gt; for admin roles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attestation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;'direct'&lt;/code&gt; (verify authenticator model)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;'none'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;'none'&lt;/code&gt; unless policy demands specific hardware&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RP ID&lt;/td&gt;
&lt;td&gt;Exact host (&lt;code&gt;app.example.com&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Registrable domain (&lt;code&gt;example.com&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Registrable domain if subdomains share login&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rollout&lt;/td&gt;
&lt;td&gt;Replace passwords&lt;/td&gt;
&lt;td&gt;Add as an option&lt;/td&gt;
&lt;td&gt;Add as an option, measure, then promote&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The RP ID choice is permanent for existing credentials. A passkey registered for &lt;code&gt;app.example.com&lt;/code&gt; cannot be used on &lt;code&gt;example.com&lt;/code&gt;. Pick the broadest domain you control and will keep, before the first user registers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: registration on the server
&lt;/h2&gt;

&lt;p&gt;I use SimpleWebAuthn on Node because it handles the CBOR and COSE parsing that is easy to get wrong. Registration has two endpoints: one to create options, one to verify the browser's response.&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;// auth/passkeys.ts (Express, SimpleWebAuthn v11+)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;generateRegistrationOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verifyRegistrationResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'@simplewebauthn/server'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;rpID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'example.com'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;expectedOrigin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'https://example.com'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'https://app.example.com'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/passkeys/register/options'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requireLogin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;res&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="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;passkeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&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="n"&gt;options&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;generateRegistrationOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;rpName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Example App'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;rpID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;userName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;userID&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;TextEncoder&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;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;webauthnId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// random, not the email or DB id&lt;/span&gt;
    &lt;span class="n"&gt;attestationType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'none'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;excludeCredentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;existing&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="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;credentialId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;transports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transports&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="n"&gt;authenticatorSelection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;residentKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'preferred'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userVerification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'preferred'&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="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;regChallenge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// single use, server side&lt;/span&gt;
  &lt;span class="n"&gt;res&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="n"&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="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/passkeys/register/verify'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requireLogin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;res&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="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;verified&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;registrationInfo&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="nf"&gt;verifyRegistrationResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;expectedChallenge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;regChallenge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;expectedOrigin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;expectedRPID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rpID&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="k"&gt;delete&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;regChallenge&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="n"&gt;verified&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;registrationInfo&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&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="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="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'verification_failed'&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="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;credentialDeviceType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;credentialBackedUp&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;registrationInfo&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;passkeys&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="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;credentialId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;credential&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="n"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Buffer&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="n"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;transports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transports&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="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;deviceType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;credentialDeviceType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// 'singleDevice' | 'multiDevice'&lt;/span&gt;
    &lt;span class="n"&gt;backedUp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;credentialBackedUp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s1"&gt;'Passkey'&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'passkey_registered'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;res&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="n"&gt;ok&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="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;Three details here prevent most support tickets. &lt;code&gt;excludeCredentials&lt;/code&gt; stops users registering the same authenticator twice. The &lt;code&gt;userID&lt;/code&gt; is a random handle, so you never leak an email or database ID to authenticators. And &lt;code&gt;requireLogin&lt;/code&gt; means registration only happens inside an authenticated session, ideally a recent one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: sign-in with browser autofill
&lt;/h2&gt;

&lt;p&gt;Conditional UI lets the browser offer passkeys in the username field's autofill menu, so users do not need to know what a passkey is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"username"&lt;/span&gt; &lt;span class="na"&gt;autocomplete&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"username webauthn"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;startAuthentication&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;browserSupportsWebAuthnAutofill&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'@simplewebauthn/browser'&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;browserSupportsWebAuthnAutofill&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="n"&gt;optionsJSON&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/passkeys/login/options'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'POST'&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;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&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="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="n"&gt;authResp&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;startAuthentication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;optionsJSON&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;useBrowserAutofill&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="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/passkeys/login/verify'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'application/json'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&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="n"&gt;authResp&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;On the server, look up the stored credential by &lt;code&gt;response.id&lt;/code&gt;, call &lt;code&gt;verifyAuthenticationResponse&lt;/code&gt; with the stored public key and counter, then update the counter and a &lt;code&gt;lastUsedAt&lt;/code&gt; column. Synced passkeys usually report a counter of zero, so treat a non-increasing counter as a signal to log, not an automatic lockout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: debug failures from the response itself
&lt;/h2&gt;

&lt;p&gt;When verification fails, the browser's response already tells you why. The &lt;code&gt;clientDataJSON&lt;/code&gt; field is base64url-encoded JSON containing &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;challenge&lt;/code&gt; and &lt;code&gt;origin&lt;/code&gt;. Paste it into a &lt;a href="https://pixelvalut.com/tools/base64?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=passkeys-rollout-without-lockouts" rel="noopener noreferrer"&gt;Base64 decoder&lt;/a&gt; (switch to base64url) and pretty-print the result with a &lt;a href="https://pixelvalut.com/tools/json-formatter?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=passkeys-rollout-without-lockouts" rel="noopener noreferrer"&gt;JSON formatter&lt;/a&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="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"webauthn.get"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"challenge"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"q3kV1x..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"origin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"https://app.example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"crossOrigin"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my outage, &lt;code&gt;origin&lt;/code&gt; was &lt;code&gt;https://app.example.com&lt;/code&gt; while the server expected only &lt;code&gt;https://example.com&lt;/code&gt;. Comparing &lt;code&gt;challenge&lt;/code&gt; with what you stored in the session catches the other classic bug: a second tab or a retry overwrote the challenge. Only decode responses from your own test accounts or your own logs, and never log full responses in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: fallbacks and rollout metrics
&lt;/h2&gt;

&lt;p&gt;Roll out in phases. First, offer "Add a passkey" in account settings to signed-in users. Second, prompt after a successful password login. Third, once success rates are high, make passkey the first option at sign-in while keeping the password and recovery paths. Track per phase: registration completion rate, passkey sign-in success rate, fallback rate to password and support tickets tagged "passkey". If fallback rises after a browser release, you know where to look.&lt;/p&gt;

&lt;p&gt;Let users see, rename and delete their passkeys, and show &lt;code&gt;lastUsedAt&lt;/code&gt;. Ask them to register a second one. A user with two passkeys almost never needs recovery.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnostic checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] RP ID chosen deliberately and documented; it matches every login origin.&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;expectedOrigin&lt;/code&gt; lists every real origin, including subdomains and native app origins.&lt;/li&gt;
&lt;li&gt;[ ] Challenges are random, stored server side, single use and short lived.&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;userID&lt;/code&gt; is a random handle, not an email or database ID.&lt;/li&gt;
&lt;li&gt;[ ] Credentials are stored with public key, counter, transports, device type and backup state.&lt;/li&gt;
&lt;li&gt;[ ] Registration requires an authenticated, recent session.&lt;/li&gt;
&lt;li&gt;[ ] Users can list, rename and remove passkeys and are encouraged to add a second.&lt;/li&gt;
&lt;li&gt;[ ] Password or recovery fallback remains and is tested.&lt;/li&gt;
&lt;li&gt;[ ] Registration, sign-in success and fallback rates are measured per browser.&lt;/li&gt;
&lt;li&gt;[ ] Passkey added or removed events are audited and the user is notified.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Do passkeys replace passwords immediately?
&lt;/h3&gt;

&lt;p&gt;Not on day one. Offer them alongside passwords, measure, and only remove passwords for users who have at least two passkeys and a tested recovery method.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if a user loses their phone?
&lt;/h3&gt;

&lt;p&gt;Synced passkeys restore through their platform account. Device-bound ones do not, which is why a second passkey and a solid recovery flow matter. Part 4 covers recovery hardening.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I require attestation?
&lt;/h3&gt;

&lt;p&gt;Only if you must restrict which authenticators are allowed, for example hardware keys for admins. For most apps, &lt;code&gt;'none'&lt;/code&gt; reduces friction and privacy concerns.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I share passkeys across subdomains?
&lt;/h3&gt;

&lt;p&gt;Yes, if the RP ID is the parent domain and every subdomain origin is listed in your expected origins.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do passkeys work with my existing sessions?
&lt;/h3&gt;

&lt;p&gt;Yes. A passkey sign-in should create the same hardened session as any other login. Part 2 covers rotation, timeouts and revocation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a library necessary?
&lt;/h3&gt;

&lt;p&gt;Strongly recommended. Parsing CBOR, COSE keys and attestation by hand is where subtle verification bugs come from.&lt;/p&gt;

&lt;p&gt;Next in this series, Part 2 covers session lifecycle hardening after sign-in.&lt;/p&gt;




&lt;h3&gt;
  
  
  Follow &amp;amp; Connect
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Website:&lt;/strong&gt; &lt;a href="https://pixelvalut.com/tools/?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=passkeys-rollout-without-lockouts" rel="noopener noreferrer"&gt;PixelValut Tools&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;YouTube:&lt;/strong&gt; &lt;a href="https://www.youtube.com/@pixel-valut" rel="noopener noreferrer"&gt;PixelValut (@pixel-valut)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TikTok:&lt;/strong&gt; &lt;a href="https://www.tiktok.com/@pixelvalut007" rel="noopener noreferrer"&gt;PixelValut (@pixelvalut007)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ali111887" rel="noopener noreferrer"&gt;Muhammad Ali Mughal (@ali111887)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/muhammad-ali-m-03434a120/" rel="noopener noreferrer"&gt;Muhammad Ali Mughal&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>authentication</category>
    </item>
    <item>
      <title>How to Stand Out When Every Application Looks the Same</title>
      <dc:creator>Muhammad Ali Mughal</dc:creator>
      <pubDate>Fri, 13 Nov 2026 16:11:25 +0000</pubDate>
      <link>https://dev.to/muhammadalimughal/how-to-stand-out-when-every-application-looks-the-same-2a8p</link>
      <guid>https://dev.to/muhammadalimughal/how-to-stand-out-when-every-application-looks-the-same-2a8p</guid>
      <description>&lt;p&gt;Last month a hiring manager I work with opened a backend role on a Monday morning. By Wednesday it had 1,140 applications. She showed me the first twenty she had skimmed. Eleven opened with almost the same sentence: "Results-driven software engineer with a passion for building scalable solutions." Fourteen listed "Python, AWS, Docker, Kubernetes, CI/CD" in the same order. Most of them were probably decent engineers. She could not tell, so she moved on, and spent her time on the three that told her something specific in the first few lines.&lt;/p&gt;

&lt;p&gt;I wrote this with help from AI tools and checked every example, rewrite and checklist item myself. I am a Principal Software Engineer in Karachi with 9+ years across PHP/Laravel, Vue, Node and .NET microservices. I sit on hiring panels, and I build Smart Resume Analyzer, so I see this problem from both sides: the reader drowning in sameness, and the candidate who is good but reads like everyone else.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;📚 Series Navigation — Standing Out in AI-Era Tech Hiring&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1 (This Post):&lt;/strong&gt; How to Stand Out When Every Application Looks the Same&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 2:&lt;/strong&gt; Proving AI-Assisted Productivity on a Resume Without Overclaiming&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3:&lt;/strong&gt; Preparing for AI Interviewers &amp;amp; Automated First-Round Screens&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 4:&lt;/strong&gt; Public Work That Compounds: Writing, Talks &amp;amp; Code Reviews as Signals&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 5:&lt;/strong&gt; Skills Half-Life: Repositioning Your Resume as Your Stack Shifts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 6:&lt;/strong&gt; Referrals &amp;amp; Warm Intros in a Flooded Applicant Pool&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Related earlier posts:&lt;/em&gt; &lt;a href="https://dev.to/muhammadalimughal/the-one-story-resume-test-building-a-flagship-project-section-that-still-passes-ats-28co"&gt;The One-Story Resume Test&lt;/a&gt; · &lt;a href="https://dev.to/muhammadalimughal/how-ai-llm-resume-screeners-rank-engineers-in-2026-vector-matching-vs-keyword-ats-2h0p"&gt;How AI &amp;amp; LLM Resume Screeners Rank Engineers in 2026&lt;/a&gt; · &lt;a href="https://dev.to/muhammadalimughal/negotiating-level-from-evidence-mapping-proof-to-staff-vs-senior-4c4n"&gt;Negotiating Level From Evidence&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How do you stand out when hundreds of applications look the same?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Direct answer:&lt;/strong&gt; Increase signal density in the top third of page one. Replace generic claims with specifics only you can make: named systems, real numbers, constraints you worked under and decisions you owned. Match the job's own vocabulary so screeners rank you, then give the human reader one concrete reason to stop. Sameness is the default; specificity is the differentiator.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe1vmxghzslomzsnuuqew.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe1vmxghzslomzsnuuqew.jpg" alt="A single sheet of paper set apart from a stack in morning light, AI-generated" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why everyone sounds the same now
&lt;/h2&gt;

&lt;p&gt;Two things changed at once. Applying became almost free: one-click apply plus an AI tool that "tailors" a resume in seconds. And those tools all learned from the same public resume advice, so they converge on the same phrases, the same verb list and the same skills ordering. The result is a pile of documents that are individually fine and collectively indistinguishable.&lt;/p&gt;

&lt;p&gt;On the other side, the reader has less time per document, not more. A recruiter skimming 300 resumes for a shortlist of 15 is looking for reasons to keep reading. Generic text does not give them one. It is not penalised; it is just skipped.&lt;/p&gt;

&lt;p&gt;That changes what "standing out" means. It is not about clever formatting or a bold colour. It is about how much verifiable, specific information a reader gets per second.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signal density test
&lt;/h2&gt;

&lt;p&gt;I use a simple test on resumes people send me. Take the top third of page one: name, headline, summary if any, and the first role's first three bullets. Highlight every phrase that could only be true of you: a named system, a number, a constraint, a decision. Then count.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phrase type&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Generic trait&lt;/td&gt;
&lt;td&gt;"Passionate, results-driven engineer"&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Every applicant can claim it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool list&lt;/td&gt;
&lt;td&gt;"Python, AWS, Docker, Kubernetes"&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Needed for keyword match, says nothing about depth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Responsibility&lt;/td&gt;
&lt;td&gt;"Responsible for backend services"&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Describes the job, not what you did&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Outcome with number&lt;/td&gt;
&lt;td&gt;"Cut p95 checkout latency from 1.8 s to 420 ms"&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Specific, checkable, invites a question&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Constraint plus decision&lt;/td&gt;
&lt;td&gt;"Migrated billing to Postgres with zero downtime during peak season"&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Shows judgement under pressure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scope&lt;/td&gt;
&lt;td&gt;"Owned payments for 3 product teams, 40k merchants"&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Lets the reader place your level&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If the top third has fewer than four high-signal phrases, it reads like the pile. Most resumes I see have one or two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before and after: the same engineer
&lt;/h2&gt;

&lt;p&gt;Here is a real top section, anonymised, from a mid-level backend engineer who was getting no callbacks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEFORE
Software Engineer | Python | AWS | Microservices
Passionate engineer with 5 years of experience building scalable,
high-performance backend systems. Strong team player.

Acme Logistics — Backend Engineer (2022–present)
- Responsible for developing and maintaining microservices
- Worked with AWS, Docker and Kubernetes
- Collaborated with cross-functional teams to deliver features
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And after one working session where we asked "what did you actually change?" for each line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AFTER
Backend Engineer — logistics APIs, Python/FastAPI on AWS (5 yrs)

Acme Logistics — Backend Engineer (2022–present)
- Rebuilt the shipment-tracking API (FastAPI, Postgres, SQS) serving
  2.3M requests/day; cut p95 latency from 1.4 s to 310 ms
- Led the move of 6 services from EC2 to EKS with zero customer-facing
  downtime; cut monthly compute spend by 28%
- Designed carrier webhook ingestion with idempotency keys; duplicate
  shipment events dropped from ~1,900/week to under 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The keywords are all still there: Python, AWS, Kubernetes (as EKS), microservices. But now each one is attached to something only this person did. He got three interviews from the next twelve applications. Nothing about his experience changed; only how much of it reached the reader.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: mirror the job's vocabulary, then add specifics
&lt;/h2&gt;

&lt;p&gt;Standing out does not mean ignoring the screener. Ranking systems, whether keyword ATS filters or embedding-based matchers, still decide whether a human ever opens your file. So I do two passes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Vocabulary pass.&lt;/strong&gt; Pull the job's actual terms and make sure the ones you genuinely have appear in your bullets, not only in a skills list. If the posting says "event-driven architecture" and you wrote "message queues", use their phrase where it is true. A &lt;a href="https://smartresumeanalyzer.com/job-description-keyword-finder?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=stand-out-flooded-applicant-pool" rel="noopener noreferrer"&gt;job description keyword finder&lt;/a&gt; makes this quick.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Specificity pass.&lt;/strong&gt; For every bullet that now contains a keyword, attach a number, a named system or a decision. A keyword alone gets you ranked; a keyword with evidence gets you read.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then check the match as a whole. I paste the resume and the posting into a &lt;a href="https://smartresumeanalyzer.com/resume-job-match?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=stand-out-flooded-applicant-pool" rel="noopener noreferrer"&gt;resume and job match checker&lt;/a&gt; and look at which of the posting's top requirements have no supporting bullet. Those gaps matter more than any wording tweak.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: lead with your one differentiator
&lt;/h2&gt;

&lt;p&gt;Most engineers have one thing that is unusual about them: a domain (payments, healthcare, logistics), a rare combination (frontend plus infra), a scale story, or a type of problem they keep getting pulled into. Put it in the headline line, in plain words.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Weak: "Full-Stack Software Engineer"&lt;/li&gt;
&lt;li&gt;Strong: "Full-stack engineer — Vue/Laravel, 6 years in payments and PCI-scoped systems"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The strong version is still searchable for "full-stack" and "Laravel", but it also tells a fintech hiring manager in two seconds why to keep reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: cut what everyone else has
&lt;/h2&gt;

&lt;p&gt;Removing text raises density as much as adding it. I delete:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Objective statements and personality adjectives.&lt;/li&gt;
&lt;li&gt;Bullets that restate the job title ("Developed software").&lt;/li&gt;
&lt;li&gt;Tools listed only in a skills section with no supporting bullet, unless the posting requires them.&lt;/li&gt;
&lt;li&gt;Old roles described at the same length as the current one. Compress anything older than about eight years to one line.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 4: make one detail invite a question
&lt;/h2&gt;

&lt;p&gt;Interviewers like to ask about something concrete. Give them one strong hook per role: a surprising number, an unusual constraint or a hard trade-off. "Kept the legacy SOAP integration alive for a 9-year-old carrier contract while migrating everything else" will get asked about. That question is a conversation you already know how to win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnostic checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] The top third of page one has at least four high-signal phrases (numbers, named systems, constraints, decisions).&lt;/li&gt;
&lt;li&gt;[ ] The headline names your differentiator, not just your title.&lt;/li&gt;
&lt;li&gt;[ ] Every keyword you claim appears in at least one bullet with evidence.&lt;/li&gt;
&lt;li&gt;[ ] The posting's top five requirements each have a supporting bullet.&lt;/li&gt;
&lt;li&gt;[ ] No objective statement, personality adjectives or "responsible for" bullets.&lt;/li&gt;
&lt;li&gt;[ ] Roles older than about eight years are compressed.&lt;/li&gt;
&lt;li&gt;[ ] Each recent role has one detail that invites a question.&lt;/li&gt;
&lt;li&gt;[ ] You read the top third aloud and it could not be mistaken for anyone else's.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Should I stop using AI to write my resume?
&lt;/h3&gt;

&lt;p&gt;No, but use it to edit your facts, not to invent phrasing. Feed it your real numbers and systems and ask it to tighten. If it hands you "results-driven", delete it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does a creative design help me stand out?
&lt;/h3&gt;

&lt;p&gt;Rarely. It often breaks parsing and the reader still looks for content. Stand out with specifics in a clean, single-column layout.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if I do not have impressive numbers?
&lt;/h3&gt;

&lt;p&gt;Use other specifics: scope (teams, users, services), constraints (legacy, regulated, tiny team) and decisions. "Chose to keep the monolith and extract only billing" is a strong signal without any metric.&lt;/p&gt;

&lt;h3&gt;
  
  
  How many keywords from the posting should I include?
&lt;/h3&gt;

&lt;p&gt;Every requirement you genuinely meet, each in context. Do not paste in skills you lack; it fails at interview and erodes trust.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a summary section worth keeping?
&lt;/h3&gt;

&lt;p&gt;Only if it adds a differentiator in one or two lines. A summary that repeats your title and adjectives is wasted space.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I know if it is working?
&lt;/h3&gt;

&lt;p&gt;Track callbacks per ten applications to comparable roles. If the rate does not move after a rewrite, the issue is likely targeting or match, not wording.&lt;/p&gt;

&lt;p&gt;Next in this series, Part 2 covers how to show AI-assisted productivity on a resume without overclaiming.&lt;/p&gt;




&lt;h3&gt;
  
  
  Follow &amp;amp; Connect
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Website:&lt;/strong&gt; &lt;a href="https://smartresumeanalyzer.com/?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=stand-out-flooded-applicant-pool" rel="noopener noreferrer"&gt;Smart Resume Analyzer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;YouTube:&lt;/strong&gt; &lt;a href="https://www.youtube.com/@resume-boost-ai" rel="noopener noreferrer"&gt;Resume Boost AI (@resume-boost-ai)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TikTok:&lt;/strong&gt; &lt;a href="https://www.tiktok.com/@resumeboostai" rel="noopener noreferrer"&gt;Resume Boost AI (@resumeboostai)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X:&lt;/strong&gt; &lt;a href="https://x.com/AliMughal887" rel="noopener noreferrer"&gt;Muhammad Ali Mughal (@AliMughal887)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ali111887" rel="noopener noreferrer"&gt;Muhammad Ali Mughal (@ali111887)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/muhammad-ali-m-03434a120/" rel="noopener noreferrer"&gt;Muhammad Ali Mughal&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>career</category>
      <category>resume</category>
      <category>hiring</category>
      <category>ai</category>
    </item>
    <item>
      <title>Synthetic Checks &amp; SLOs for the Paths Nobody Clicks</title>
      <dc:creator>Muhammad Ali Mughal</dc:creator>
      <pubDate>Fri, 13 Nov 2026 16:10:00 +0000</pubDate>
      <link>https://dev.to/muhammadalimughal/synthetic-checks-slos-for-the-paths-nobody-clicks-4hhl</link>
      <guid>https://dev.to/muhammadalimughal/synthetic-checks-slos-for-the-paths-nobody-clicks-4hhl</guid>
      <description>&lt;p&gt;A client's status page showed 99.98% uptime for the quarter. In the same quarter, their password reset flow was broken for 11 days. The homepage returned 200, the API health endpoint returned 200, and the load balancer was happy. But a queue worker that sent reset emails had been pinned to an old mail driver after a config change, and the reset page itself rendered fine; it just never sent anything. Nobody alerted because nobody clicks "forgot password" often enough for error rates to move. We found out from a support ticket written in capital letters.&lt;/p&gt;

&lt;p&gt;I wrote this with help from AI tools and checked every script, config and query myself. I am a Principal Software Engineer in Karachi with 9+ years across PHP/Laravel, Vue, Node and .NET microservices, and at Loop Origin we run and maintain production servers for SaaS clients. This is Part 6, the finale of the series Silent Failures in Production SaaS. This last part is the safety net under the earlier five: actively exercising the paths real users rarely touch and measuring them against an objective.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;📚 Series Navigation — Silent Failures in Production SaaS&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/cron-jobs-that-never-ran-heartbeats-dead-man-switches-for-saas-1gk6"&gt;Cron Jobs That Never Ran: Heartbeats &amp;amp; Dead-Man Switches for SaaS&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 2:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/webhooks-that-stopped-arriving-gap-detection-reconciliation-jo7"&gt;Webhooks That Stopped Arriving: Gap Detection &amp;amp; Reconciliation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/email-that-never-landed-spfdkimdmarc-drift-bounce-pipelines-999"&gt;Email That Never Landed: SPF/DKIM/DMARC Drift &amp;amp; Bounce Pipelines&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 4:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/config-feature-flags-that-quietly-diverged-across-environments-4m38"&gt;Config &amp;amp; Feature Flags That Quietly Diverged Across Environments&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 5:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/backups-that-never-restored-automated-restore-drills-1amb"&gt;Backups That Never Restored: Automated Restore Drills&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 6 (This Post, Finale):&lt;/strong&gt; Synthetic Checks &amp;amp; SLOs for the Paths Nobody Clicks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Related earlier posts:&lt;/em&gt; &lt;a href="https://dev.to/muhammadalimughal/production-observability-on-a-budget-opentelemetry-traces-structured-logs-tail-sampling-360a"&gt;Production Observability on a Budget&lt;/a&gt; · &lt;a href="https://dev.to/muhammadalimughal/observability-for-async-work-correlation-ids-job-traces-16f3"&gt;Observability for Async Work: Correlation IDs &amp;amp; Job Traces&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How do you catch failures on paths real users rarely use?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Direct answer:&lt;/strong&gt; Run synthetic checks: scheduled scripts that perform the real user journey end to end (request a password reset, receive the email, follow the link, export an invoice) using a dedicated test tenant. Record each run as pass or fail with timing, define an SLO per journey, and alert on error-budget burn rather than on single failures or plain uptime.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fznqeeztoq6ma52spt7pq.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fznqeeztoq6ma52spt7pq.jpg" alt="Lighthouse beam sweeping across a misty sea at dusk, AI-generated" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why uptime monitoring misses the important failures
&lt;/h2&gt;

&lt;p&gt;Uptime checks answer one question: does a URL respond? Low-traffic paths fail in ways that never touch that question. Here is how the three common layers compare on the failures from this series:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;Uptime ping&lt;/th&gt;
&lt;th&gt;Error-rate alert on real traffic&lt;/th&gt;
&lt;th&gt;Synthetic journey check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Password reset email never sent&lt;/td&gt;
&lt;td&gt;Misses (page renders)&lt;/td&gt;
&lt;td&gt;Misses (no exception, few users)&lt;/td&gt;
&lt;td&gt;Catches (no email within 2 min)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSO login broken for one IdP&lt;/td&gt;
&lt;td&gt;Misses&lt;/td&gt;
&lt;td&gt;Misses until a big customer logs in&lt;/td&gt;
&lt;td&gt;Catches (test IdP login fails)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invoice PDF export times out&lt;/td&gt;
&lt;td&gt;Misses&lt;/td&gt;
&lt;td&gt;Maybe, if volume is high&lt;/td&gt;
&lt;td&gt;Catches (export &amp;gt; 30 s)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Webhook endpoint returns 200 but drops data&lt;/td&gt;
&lt;td&gt;Misses&lt;/td&gt;
&lt;td&gt;Misses&lt;/td&gt;
&lt;td&gt;Catches (round-trip record not found)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signup blocked by a new CAPTCHA config&lt;/td&gt;
&lt;td&gt;Misses&lt;/td&gt;
&lt;td&gt;Misses (users just leave)&lt;/td&gt;
&lt;td&gt;Catches (signup step fails)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Homepage down&lt;/td&gt;
&lt;td&gt;Catches&lt;/td&gt;
&lt;td&gt;Catches&lt;/td&gt;
&lt;td&gt;Catches&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The bottom row is the only one uptime monitoring is designed for, and it is also the failure you would notice anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: pick the journeys, not the endpoints
&lt;/h2&gt;

&lt;p&gt;I list journeys by asking two questions: if this broke for a week, would it cost money or trust? And does it get fewer than a few hundred uses a day? Anything that answers yes to both gets a synthetic check. For a typical B2B SaaS that is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Signup and email verification&lt;/li&gt;
&lt;li&gt;Password reset end to end&lt;/li&gt;
&lt;li&gt;SSO login for each identity provider type you support&lt;/li&gt;
&lt;li&gt;Invoice or report export&lt;/li&gt;
&lt;li&gt;Inbound webhook round trip&lt;/li&gt;
&lt;li&gt;Plan upgrade on a test payment method&lt;/li&gt;
&lt;li&gt;Account deletion or data export request (often a legal obligation)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use a dedicated synthetic tenant with clearly labelled data, excluded from analytics and billing. Never point checks at real customer accounts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: write the check as a real browser journey
&lt;/h2&gt;

&lt;p&gt;For flows that involve a UI, I use Playwright. This is a trimmed version of our password reset check. The test mailbox is a catch-all on a subdomain we control, read through the mail provider's inbound API:&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;// synthetics/password-reset.spec.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'@playwright/test'&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="n"&gt;waitForEmail&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'./mailbox'&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="s1"&gt;'password reset end to end'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="p"&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="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;SYNTH_USER_EMAIL&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="n"&gt;started&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="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`${process.env.APP_URL}/forgot-password`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Email'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'button'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt; &lt;span class="n"&gt;reset&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;i&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;click&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;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;we&lt;/span&gt; &lt;span class="n"&gt;have&lt;/span&gt; &lt;span class="n"&gt;emailed&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;i&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;toBeVisible&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="n"&gt;msg&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;waitForEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;to&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;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;reset&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sinceMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;120_000&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="n"&gt;link&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;html&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="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;href&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="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;reset&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;^&lt;/span&gt;&lt;span class="s2"&gt;"]+)"&lt;/span&gt;&lt;span class="o"&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="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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'reset link present in email'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBeTruthy&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;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;link&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;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Password'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;exact&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="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;SYNTH_USER_PASSWORD&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;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Confirm Password'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;SYNTH_USER_PASSWORD&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;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'button'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;reset&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;i&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;click&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;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toHaveURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;dashboard&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;login&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="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 email step is what would have caught the 11-day outage. A check that stopped at "we have emailed you" would have passed every single run.&lt;/p&gt;

&lt;p&gt;For API-only journeys, a plain script that posts a signed test event and polls until the record exists is enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: record results as metrics, not just alerts
&lt;/h2&gt;

&lt;p&gt;Each run writes one row: journey, pass or fail, duration, timestamp. In Laravel I keep a tiny table and a scheduled command, but pushing to Prometheus via a Pushgateway works just as well:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Console/Commands/RunSynthetics.php (handle method, trimmed)&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'synthetics.journeys'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$journey&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;microtime&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="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Process&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;180&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;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$cmd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nc"&gt;SyntheticRun&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'journey'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$journey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'ok'&lt;/span&gt;          &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;successful&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="s1"&gt;'duration_ms'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&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;microtime&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="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$start&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="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'output'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;errorOutput&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="nv"&gt;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;output&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="mi"&gt;2000&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// routes/console.php&lt;/span&gt;
&lt;span class="nc"&gt;Schedule&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'synthetics:run'&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;everyFiveMinutes&lt;/span&gt;&lt;span class="p"&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;withoutOverlapping&lt;/span&gt;&lt;span class="p"&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;onOneServer&lt;/span&gt;&lt;span class="p"&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;pingOnSuccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'services.heartbeats.synthetics'&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;Note the heartbeat. As Part 1 argued, a monitor that silently stops running is the worst silent failure of all, so the synthetic runner itself reports in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: define SLOs per journey and alert on burn rate
&lt;/h2&gt;

&lt;p&gt;A single failed run is often noise: a slow mail provider, a deploy in progress. Paging on every failure trains people to ignore pages. Instead I set an SLO per journey and alert when the error budget burns too fast.&lt;/p&gt;

&lt;p&gt;Example: password reset should succeed in 99% of synthetic runs over 30 days. At one run every five minutes, that is 8,640 runs and an error budget of about 86 failures. The query for the current failure ratio:&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;journey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;failure_ratio_1h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="no"&gt;PERCENTILE_CONT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="no"&gt;WITHIN&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;duration_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;p95_ms&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;synthetic_runs&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;now&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="kt"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'1 hour'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;journey&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alerting rules I use as defaults, borrowed from the multi-window burn-rate approach in Google's SRE workbook:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Alert&lt;/th&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fast burn&lt;/td&gt;
&lt;td&gt;Failure ratio over 1 h &amp;gt; 14.4× budget rate, confirmed over 5 min&lt;/td&gt;
&lt;td&gt;Budget gone in about 2 days&lt;/td&gt;
&lt;td&gt;Page on-call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slow burn&lt;/td&gt;
&lt;td&gt;Failure ratio over 6 h &amp;gt; 6× budget rate, confirmed over 30 min&lt;/td&gt;
&lt;td&gt;Budget gone in about 5 days&lt;/td&gt;
&lt;td&gt;Ticket, fix this week&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latency&lt;/td&gt;
&lt;td&gt;p95 duration &amp;gt; 2× baseline for 1 h&lt;/td&gt;
&lt;td&gt;Degrading, not broken&lt;/td&gt;
&lt;td&gt;Ticket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Silence&lt;/td&gt;
&lt;td&gt;No synthetic heartbeat for 15 min&lt;/td&gt;
&lt;td&gt;Monitoring is down&lt;/td&gt;
&lt;td&gt;Page on-call&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With a 99% target the budget rate is 1%, so "fast burn" means more than about 14% of runs failing in the last hour. Two consecutive failed runs of a five-minute check will usually trip it, which is the behaviour you want for a fully broken path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: keep the checks honest
&lt;/h2&gt;

&lt;p&gt;Synthetic checks rot like any other code. Run them against production from outside your network so DNS, TLS and CDN problems show up, keep the synthetic user's credentials in your secret manager, fix or delete flaky checks instead of muting them, and add a check for every incident where a customer told you first.&lt;/p&gt;

&lt;p&gt;We set up and run these as part of our &lt;a href="https://looporigin.com/services/server-setup-maintenance?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=synthetic-checks-slos-unclicked-paths" rel="noopener noreferrer"&gt;server setup and maintenance&lt;/a&gt; work. If you want help choosing journeys and SLO targets for your own product, &lt;a href="https://looporigin.com/contact?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=synthetic-checks-slos-unclicked-paths" rel="noopener noreferrer"&gt;get in touch with us&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnostic checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] You have a written list of low-traffic, high-value journeys.&lt;/li&gt;
&lt;li&gt;[ ] Each journey has a synthetic check that completes the whole flow, including email or webhook steps.&lt;/li&gt;
&lt;li&gt;[ ] Checks use a dedicated, labelled synthetic tenant excluded from analytics and billing.&lt;/li&gt;
&lt;li&gt;[ ] Checks run against production from outside your network at least every 5–15 minutes.&lt;/li&gt;
&lt;li&gt;[ ] Every run is stored with pass or fail and duration.&lt;/li&gt;
&lt;li&gt;[ ] Each journey has an SLO and burn-rate alerts instead of single-failure pages.&lt;/li&gt;
&lt;li&gt;[ ] The synthetic runner has its own heartbeat; silence pages someone.&lt;/li&gt;
&lt;li&gt;[ ] Flaky checks are fixed or deleted within a week, never muted indefinitely.&lt;/li&gt;
&lt;li&gt;[ ] Every "customer told us first" incident produces a new check.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Aren't synthetic checks just end-to-end tests?
&lt;/h3&gt;

&lt;p&gt;They share tooling, but the purpose differs. E2E tests run before deploy against a test environment. Synthetic checks run continuously against production to catch what changes after deploy: config, third parties, certificates and infrastructure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Won't synthetic traffic pollute my metrics?
&lt;/h3&gt;

&lt;p&gt;Only if you let it. Tag the synthetic tenant, exclude it in analytics and billing, and add a request header your logs can filter on.&lt;/p&gt;

&lt;h3&gt;
  
  
  How many journeys should I start with?
&lt;/h3&gt;

&lt;p&gt;Three to five. Password reset, signup and your main export or integration path cover most of the silent failures I see.&lt;/p&gt;

&lt;h3&gt;
  
  
  What SLO target should I pick?
&lt;/h3&gt;

&lt;p&gt;Start lower than you think, often 99% for synthetic journeys, then tighten once a month of data shows what is realistic. A target you constantly miss gets ignored.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use a hosted service instead of building this?
&lt;/h3&gt;

&lt;p&gt;Yes. Checkly, Datadog Synthetics, Grafana Synthetic Monitoring and similar tools handle scheduling and storage. The important work, choosing journeys and writing the full flow including email, is the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Series wrap-up
&lt;/h2&gt;

&lt;p&gt;That closes Silent Failures in Production SaaS. Cron jobs, webhooks, email, config drift, backups and now unclicked paths: the common thread is that a system with no errors is not the same as a system that works. Give each critical behaviour a positive signal, alert when that signal goes quiet, and measure it against a target someone agreed to.&lt;/p&gt;




&lt;h3&gt;
  
  
  Follow &amp;amp; Connect
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Website:&lt;/strong&gt; &lt;a href="https://looporigin.com/?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=synthetic-checks-slos-unclicked-paths" rel="noopener noreferrer"&gt;Loop Origin&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/company/loop-origin" rel="noopener noreferrer"&gt;Loop Origin&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X:&lt;/strong&gt; &lt;a href="https://x.com/AliMughal887" rel="noopener noreferrer"&gt;Muhammad Ali Mughal (@AliMughal887)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/LoopOrigin" rel="noopener noreferrer"&gt;Loop Origin (@LoopOrigin)&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>sre</category>
      <category>monitoring</category>
      <category>laravel</category>
    </item>
    <item>
      <title>Browser Secrets That Leak: Token Storage, Quotas &amp; Outbound Controls</title>
      <dc:creator>Muhammad Ali Mughal</dc:creator>
      <pubDate>Wed, 11 Nov 2026 07:15:11 +0000</pubDate>
      <link>https://dev.to/muhammadalimughal/browser-secrets-that-leak-token-storage-quotas-outbound-controls-cf2</link>
      <guid>https://dev.to/muhammadalimughal/browser-secrets-that-leak-token-storage-quotas-outbound-controls-cf2</guid>
      <description>&lt;p&gt;During an authorised security review for a client last year I opened DevTools on their admin dashboard, went to Application, then Local Storage, and found a JWT with a 30-day expiry, an &lt;code&gt;admin: true&lt;/code&gt; claim and a refresh token sitting next to it in plain text. The app had a strict CSP, SRI on every script and a clean dependency audit. None of that mattered for this particular risk: any script that ever ran on that origin, including a browser extension with page access, could read both tokens. One bug anywhere on the origin would turn into a month of access.&lt;/p&gt;

&lt;p&gt;I wrote this with help from AI tools and checked every snippet, header and storage behaviour myself in current Chrome and Firefox. I am a Principal Software Engineer in Karachi with 9+ years across PHP/Laravel, Vue, Node and .NET microservices, and I build the client-side tools on PixelValut. This is Part 6, the finale of the series Client Trust &amp;amp; Agent Hijack Defense. &lt;a href="https://dev.to/muhammadalimughal/subresource-integrity-cdn-script-pinning-catching-silent-payload-swaps-1654"&gt;Part 3 covered SRI and CDN script pinning&lt;/a&gt;, &lt;a href="https://dev.to/muhammadalimughal/csp-trusted-types-residual-xss-reading-reports-like-a-forensics-log-h4g"&gt;Part 4 covered CSP and Trusted Types&lt;/a&gt; and &lt;a href="https://dev.to/muhammadalimughal/extension-npm-postinstall-forensics-homoglyphs-base64-unexpected-network-2g6j"&gt;Part 5 covered extension and npm postinstall forensics&lt;/a&gt;. Those parts were about keeping hostile code out. This one assumes some gets in, and asks what it can take.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;📚 Series Roadmap — Client Trust &amp;amp; Agent Hijack Defense&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/prompt-injection-via-docs-your-tools-fetch-markdown-readmes-issue-bodies-bpg"&gt;Prompt Injection via Docs Your Tools Fetch&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 2:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/webmcp-tool-calling-attack-surfaces-what-an-e-shop-can-make-an-agent-do-2981"&gt;WebMCP &amp;amp; Tool-Calling Attack Surfaces&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/subresource-integrity-cdn-script-pinning-catching-silent-payload-swaps-1654"&gt;Subresource Integrity &amp;amp; CDN Script Pinning&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 4:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/csp-trusted-types-residual-xss-reading-reports-like-a-forensics-log-h4g"&gt;CSP, Trusted Types &amp;amp; Residual XSS&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 5:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/extension-npm-postinstall-forensics-homoglyphs-base64-unexpected-network-2g6j"&gt;Extension &amp;amp; npm Postinstall Forensics&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 6 (This Post):&lt;/strong&gt; Browser Secrets That Leak: Token Storage, Quotas &amp;amp; Outbound Controls&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Related earlier posts:&lt;/em&gt; &lt;a href="https://dev.to/muhammadalimughal/oauth-21-oidc-id-tokens-pkce-forensics-auditing-claims-nonces-audience-confusion-in-spas-2eh9"&gt;OAuth 2.1 &amp;amp; OIDC ID Token PKCE Forensics&lt;/a&gt; · &lt;a href="https://dev.to/muhammadalimughal/deobfuscating-malicious-npm-browser-payloads-base64-hex-unicode-escapes-homoglyphs-39eb"&gt;Deobfuscating Malicious npm &amp;amp; Browser Payloads&lt;/a&gt; · &lt;a href="https://dev.to/muhammadalimughal/webhook-security-in-production-hmac-sha256-signatures-timestamp-drift-replay-protection-4f8a"&gt;Webhook Security in Production&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where should a web app store auth tokens so they don't leak?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Direct answer:&lt;/strong&gt; Keep long-lived credentials out of anything JavaScript can read. Use &lt;code&gt;HttpOnly&lt;/code&gt;, &lt;code&gt;Secure&lt;/code&gt;, &lt;code&gt;SameSite&lt;/code&gt; cookies (ideally &lt;code&gt;__Host-&lt;/code&gt; prefixed) for sessions and refresh tokens, and keep short-lived access tokens in memory only. Treat localStorage, sessionStorage, IndexedDB and Cache Storage as readable by any script on the origin, and limit where that script can send data.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flzv11su4w56n4bzkr09v.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flzv11su4w56n4bzkr09v.jpg" alt="Empty glass jar on a dark textured surface in moody light, AI-generated" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The threat model in one sentence
&lt;/h2&gt;

&lt;p&gt;Any script running on your origin, whether your own code, a third-party widget or injected code after an XSS bug, has the same access to JavaScript-readable storage, and can make authenticated requests while the page is open. You cannot fully prevent the second part. You can prevent the first part from turning a short incident into a 30-day account takeover.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each browser store actually exposes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Store&lt;/th&gt;
&lt;th&gt;Readable by page JS&lt;/th&gt;
&lt;th&gt;Survives tab close&lt;/th&gt;
&lt;th&gt;Sent automatically&lt;/th&gt;
&lt;th&gt;Typical leak&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;HttpOnly&lt;/code&gt; cookie&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (until expiry)&lt;/td&gt;
&lt;td&gt;Yes, to matching requests&lt;/td&gt;
&lt;td&gt;CSRF if &lt;code&gt;SameSite&lt;/code&gt; is weak; not readable by scripts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Normal cookie&lt;/td&gt;
&lt;td&gt;Yes, via &lt;code&gt;document.cookie&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Readable by any page script&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;localStorage&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes, indefinitely&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Long-lived JWTs and refresh tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sessionStorage&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No (per tab)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Same as localStorage, shorter window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IndexedDB&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Offline caches holding PII or API keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache Storage / service worker&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Cached authenticated API responses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In-memory variable&lt;/td&gt;
&lt;td&gt;Only while page runs&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Smallest window; lost on reload&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The row that surprises people is Cache Storage. A service worker that caches &lt;code&gt;/api/me&lt;/code&gt; or &lt;code&gt;/api/invoices&lt;/code&gt; for offline use is storing authenticated responses on disk where any same-origin script can call &lt;code&gt;caches.match()&lt;/code&gt; and read them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: audit what is actually stored
&lt;/h2&gt;

&lt;p&gt;Start with your own app in a staging environment, signed in as a test user. Open DevTools, go to the Application (Chrome) or Storage (Firefox) panel, and walk through each store: Cookies, Local Storage, Session Storage, IndexedDB and Cache Storage. For each entry, note the key name, what it holds and whether it is needed. Then check how much the origin is using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Run on your own app, in staging: how much does this origin store?&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quota&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="n"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;estimate&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="nb"&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="sb"&gt;`usage ${(usage / 1024).toFixed(1)} KB of ${(quota / 1048576).toFixed(0)} MB quota`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&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="s1"&gt;'persisted:'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;persisted&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;I record the findings in a short inventory that lives in the repo, so reviews can spot new entries:&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="c1"&gt;# storage-inventory.yml
&lt;/span&gt;&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localStorage&lt;/span&gt;
  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ui.theme&lt;/span&gt;
  &lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"light|dark"&lt;/span&gt;
  &lt;span class="na"&gt;sensitive&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;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cookie&lt;/span&gt;
  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;__Host-session&lt;/span&gt;
  &lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;opaque session id&lt;/span&gt;
  &lt;span class="na"&gt;sensitive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;Secure&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;HttpOnly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;SameSite=Lax&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cacheStorage&lt;/span&gt;
  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-v1&lt;/span&gt;
  &lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cached GET /api/catalog (public)&lt;/span&gt;
  &lt;span class="na"&gt;sensitive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything marked sensitive outside an HttpOnly cookie deserves a look. For test-user JWTs, paste the value into a &lt;a href="https://pixelvalut.com/tools/jwt-decoder?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=browser-secrets-tokens-storage-exfil" rel="noopener noreferrer"&gt;JWT decoder&lt;/a&gt; to check the &lt;code&gt;exp&lt;/code&gt;, &lt;code&gt;scope&lt;/code&gt; and role claims; a 30-day admin token in localStorage is a very different finding from a five-minute read-only one. The decoding runs in your browser, but for production tokens I still prefer revoking them after the audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why storage quotas matter for security
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;navigator.storage.estimate()&lt;/code&gt; is not just a performance tool. Origins get a large quota, often a meaningful fraction of free disk, and that space is readable by any script on the origin. Two things I check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unexpected growth.&lt;/strong&gt; If usage jumps after a release, something new is caching. In one review it was a chat widget storing full conversation transcripts in IndexedDB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistence.&lt;/strong&gt; If the app calls &lt;code&gt;navigator.storage.persist()&lt;/code&gt;, the browser is less likely to evict that data under pressure. That is right for offline editors and wrong for anything holding sensitive responses.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Clear sensitive stores on logout explicitly, and send &lt;code&gt;Clear-Site-Data&lt;/code&gt; from your logout endpoint so the browser wipes what your code forgot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="no"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mf"&gt;1.1&lt;/span&gt; &lt;span class="mi"&gt;204&lt;/span&gt; &lt;span class="nc"&gt;No&lt;/span&gt; &lt;span class="nc"&gt;Content&lt;/span&gt;
&lt;span class="nc"&gt;Clear&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;Site&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"cache"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"cookies"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"storage"&lt;/span&gt;
&lt;span class="na"&gt;Set-Cookie: __Host-session&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nc"&gt;Max&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;Age&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="nc"&gt;Path&lt;/span&gt;&lt;span class="o"&gt;=/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nc"&gt;Secure&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nc"&gt;HttpOnly&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nc"&gt;SameSite&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Lax&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: move tokens where scripts cannot reach
&lt;/h2&gt;

&lt;p&gt;The pattern I use for SPAs is a backend-for-frontend (BFF) or at least cookie-based refresh:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="na"&gt;Set-Cookie: __Host-refresh&lt;/span&gt;&lt;span class="o"&gt;=&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;opaque&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="o"&gt;=/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nc"&gt;Secure&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nc"&gt;HttpOnly&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nc"&gt;SameSite&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Strict&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nc"&gt;Max&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;Age&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1209600&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Access token lives only in a closure; refreshed via HttpOnly cookie&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;accessToken&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;opts&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="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="n"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;accessToken&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/auth/refresh'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'same-origin'&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;json&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="n"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&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="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&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="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`Bearer ${accessToken}`&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;accessToken&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;opts&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;return&lt;/span&gt; &lt;span class="n"&gt;res&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 &lt;code&gt;__Host-&lt;/code&gt; prefix forces &lt;code&gt;Secure&lt;/code&gt;, &lt;code&gt;Path=/&lt;/code&gt; and no &lt;code&gt;Domain&lt;/code&gt; attribute, so a subdomain cannot plant or override the cookie. Keep access tokens short-lived, five to fifteen minutes, and bind refresh tokens to rotation so a stolen one is detected on reuse. If your API also verifies signed tokens, test that it rejects tampered ones; the &lt;a href="https://pixelvalut.com/tools/jwt-signature-verifier?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=browser-secrets-tokens-storage-exfil" rel="noopener noreferrer"&gt;JWT signature verifier&lt;/a&gt; is handy for checking that a modified payload with the original signature fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: limit where data can be sent
&lt;/h2&gt;

&lt;p&gt;Keeping secrets unreadable is the main defence. The second layer is limiting which destinations the page is allowed to contact, so injected code has fewer places to send anything. CSP covers most of these channels:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Outbound channel&lt;/th&gt;
&lt;th&gt;CSP directive&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;fetch&lt;/code&gt;, XHR, WebSocket, &lt;code&gt;sendBeacon&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;connect-src&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Allowlist your API and known analytics only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Images and pixels&lt;/td&gt;
&lt;td&gt;&lt;code&gt;img-src&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Avoid broad wildcards such as &lt;code&gt;https:&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Form submissions&lt;/td&gt;
&lt;td&gt;&lt;code&gt;form-action&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;'self'&lt;/code&gt; for most apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frames&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;frame-src&lt;/code&gt;, &lt;code&gt;child-src&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;List the payment or video providers you embed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Top-level navigation&lt;/td&gt;
&lt;td&gt;No reliable directive&lt;/td&gt;
&lt;td&gt;Another reason secrets must not be readable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extensions with page access&lt;/td&gt;
&lt;td&gt;Not controllable by the site&lt;/td&gt;
&lt;td&gt;Only HttpOnly storage protects credentials&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A tight starting point for an SPA that talks to one API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nc"&gt;Content&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;Security&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;Policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="s1"&gt;'self'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;connect&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="s1"&gt;'self'&lt;/span&gt; &lt;span class="n"&gt;https&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="c1"&gt;//api.example.com;&lt;/span&gt;
  &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="s1"&gt;'self'&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="s1"&gt;'self'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;ancestors&lt;/span&gt; &lt;span class="s1"&gt;'none'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt; &lt;span class="s1"&gt;'none'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;Content-Security-Policy-Report-Only&lt;/code&gt; first and read the reports, as covered in Part 4, before enforcing. The last two table rows are the point of this finale: some channels cannot be closed from your side, so the only reliable protection is that the credential was never readable by page scripts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnostic checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] No refresh tokens, API keys or long-lived JWTs in localStorage, sessionStorage or IndexedDB.&lt;/li&gt;
&lt;li&gt;[ ] Session and refresh cookies are &lt;code&gt;__Host-&lt;/code&gt;, &lt;code&gt;Secure&lt;/code&gt;, &lt;code&gt;HttpOnly&lt;/code&gt;, &lt;code&gt;SameSite=Lax&lt;/code&gt; or &lt;code&gt;Strict&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] Access tokens are in memory only, with a lifetime of 15 minutes or less.&lt;/li&gt;
&lt;li&gt;[ ] Refresh tokens rotate and reuse triggers revocation.&lt;/li&gt;
&lt;li&gt;[ ] Service worker does not cache authenticated API responses, or encrypts and clears them on logout.&lt;/li&gt;
&lt;li&gt;[ ] Logout sends &lt;code&gt;Clear-Site-Data&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] A storage inventory is kept in the repo and &lt;code&gt;navigator.storage.estimate()&lt;/code&gt; usage is checked after releases.&lt;/li&gt;
&lt;li&gt;[ ] CSP restricts &lt;code&gt;connect-src&lt;/code&gt;, &lt;code&gt;img-src&lt;/code&gt; and &lt;code&gt;form-action&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;[ ] Third-party widgets are reviewed for what they store on your origin.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Is localStorage ever fine for tokens?
&lt;/h3&gt;

&lt;p&gt;For short-lived, low-privilege tokens in low-risk apps it is a trade-off some teams accept. For admin, financial or long-lived tokens, no.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do HttpOnly cookies stop XSS?
&lt;/h3&gt;

&lt;p&gt;No. Injected script can still make authenticated requests while the page is open. HttpOnly stops it from stealing the credential for later use elsewhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does &lt;code&gt;SameSite=Strict&lt;/code&gt; break anything?
&lt;/h3&gt;

&lt;p&gt;It can break flows where users arrive from another site and expect to be signed in. Many apps use &lt;code&gt;Lax&lt;/code&gt; for the session and &lt;code&gt;Strict&lt;/code&gt; for the refresh cookie.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I encrypt values in localStorage?
&lt;/h3&gt;

&lt;p&gt;Encryption in the browser needs a key the same script can read, so it rarely stops same-origin attackers. It mainly helps against casual disk inspection.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I find which script wrote a storage key?
&lt;/h3&gt;

&lt;p&gt;In a debug build, set a breakpoint on storage writes in DevTools or wrap your own storage helper to log the caller, then reproduce the flow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Clear-Site-Data work everywhere?
&lt;/h3&gt;

&lt;p&gt;Support is good in current Chromium and Firefox and improving in Safari. Keep explicit client-side clearing as a backup.&lt;/p&gt;

&lt;p&gt;That closes Client Trust &amp;amp; Agent Hijack Defense. Across six parts we went from untrusted docs and agent tools to SRI, CSP, supply-chain forensics and finally storage. The thread through all of it: assume some code you did not write will run, and make sure it finds nothing worth taking.&lt;/p&gt;




&lt;h3&gt;
  
  
  Follow &amp;amp; Connect
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Website:&lt;/strong&gt; &lt;a href="https://pixelvalut.com/tools/?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=browser-secrets-tokens-storage-exfil" rel="noopener noreferrer"&gt;PixelValut Tools&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;YouTube:&lt;/strong&gt; &lt;a href="https://www.youtube.com/@pixel-valut" rel="noopener noreferrer"&gt;PixelValut (@pixel-valut)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TikTok:&lt;/strong&gt; &lt;a href="https://www.tiktok.com/@pixelvalut007" rel="noopener noreferrer"&gt;PixelValut (@pixelvalut007)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ali111887" rel="noopener noreferrer"&gt;Muhammad Ali Mughal (@ali111887)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/muhammad-ali-m-03434a120/" rel="noopener noreferrer"&gt;Muhammad Ali Mughal&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Backups That Never Restored: Automated Restore Drills</title>
      <dc:creator>Muhammad Ali Mughal</dc:creator>
      <pubDate>Wed, 11 Nov 2026 07:11:31 +0000</pubDate>
      <link>https://dev.to/muhammadalimughal/backups-that-never-restored-automated-restore-drills-1amb</link>
      <guid>https://dev.to/muhammadalimughal/backups-that-never-restored-automated-restore-drills-1amb</guid>
      <description>&lt;p&gt;The backup job had been green for fourteen months. Every night at 02:00 it ran &lt;code&gt;mysqldump&lt;/code&gt;, gzipped the output, pushed it to object storage and posted a tick to the ops channel. Then a developer ran a migration against the wrong connection and dropped a tenant table. We pulled last night's dump, started the restore, and watched it fail at 61%: the dump had been taken without &lt;code&gt;--single-transaction&lt;/code&gt; on a set of InnoDB tables that were being written during the run, and a later change had added a view referencing a table that was dumped after it. Every backup for months had the same problem. The job never failed because writing a broken file is still a successful write.&lt;/p&gt;

&lt;p&gt;I wrote this with help from AI tools and checked every command, script and config myself on MySQL 8 and PostgreSQL 16. I am a Principal Software Engineer in Karachi with 9+ years across PHP/Laravel, Vue, Node and .NET microservices, and at Loop Origin we run and maintain production servers for SaaS clients, where a backup only counts once it has been restored. This is Part 5 of the series Silent Failures in Production SaaS. &lt;a href="https://dev.to/muhammadalimughal/cron-jobs-that-never-ran-heartbeats-dead-man-switches-for-saas-1gk6"&gt;Part 1 covered cron jobs that never ran&lt;/a&gt;, &lt;a href="https://dev.to/muhammadalimughal/email-that-never-landed-spfdkimdmarc-drift-bounce-pipelines-999"&gt;Part 3 covered email that never landed&lt;/a&gt; and &lt;a href="https://dev.to/muhammadalimughal/config-feature-flags-that-quietly-diverged-across-environments-4m38"&gt;Part 4 covered config and flag drift&lt;/a&gt;. Backups are the silent failure you only discover on your worst day.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;📚 Series Roadmap — Silent Failures in Production SaaS&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/cron-jobs-that-never-ran-heartbeats-dead-man-switches-for-saas-1gk6"&gt;Cron Jobs That Never Ran: Heartbeats &amp;amp; Dead-Man Switches for SaaS&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 2:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/webhooks-that-stopped-arriving-gap-detection-reconciliation-jo7"&gt;Webhooks That Stopped Arriving: Gap Detection &amp;amp; Reconciliation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/email-that-never-landed-spfdkimdmarc-drift-bounce-pipelines-999"&gt;Email That Never Landed: SPF/DKIM/DMARC Drift &amp;amp; Bounce Pipelines&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 4:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/config-feature-flags-that-quietly-diverged-across-environments-4m38"&gt;Config &amp;amp; Feature Flags That Quietly Diverged Across Environments&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 5 (This Post):&lt;/strong&gt; Backups That Never Restored: Automated Restore Drills&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 6:&lt;/strong&gt; Synthetic Checks &amp;amp; SLOs for the Paths Nobody Clicks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Related earlier posts:&lt;/em&gt; &lt;a href="https://dev.to/muhammadalimughal/zero-downtime-database-migrations-in-laravel-nodejs-saas-expand-contract-lock-timeouts-6lp"&gt;Zero-Downtime Database Migrations in Laravel &amp;amp; Node.js SaaS&lt;/a&gt; · &lt;a href="https://dev.to/muhammadalimughal/production-observability-on-a-budget-opentelemetry-traces-structured-logs-tail-sampling-360a"&gt;Production Observability on a Budget&lt;/a&gt; · &lt;a href="https://dev.to/muhammadalimughal/delayed-jobs-schedulers-clock-skew-gotchas-253h"&gt;Delayed Jobs, Schedulers &amp;amp; Clock Skew Gotchas&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How do you know your database backups will actually restore?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Direct answer:&lt;/strong&gt; Only by restoring them. Schedule an automated drill that pulls the latest backup into a throwaway database, restores it, runs integrity checks such as row counts, checksums and a few application queries, measures restore time against your RTO, and alerts on any failure. A backup job's exit code proves a file was written, not that it is usable.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx5aim4tfvozezwfs0ahp.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx5aim4tfvozezwfs0ahp.jpg" alt="Waves breaking on a dark beach at sunrise, AI-generated" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why backup jobs lie
&lt;/h2&gt;

&lt;p&gt;A green backup job tells you a process exited with status zero. It does not tell you any of the following, and I have seen each one in client systems:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;Why the job stays green&lt;/th&gt;
&lt;th&gt;How a drill catches it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Inconsistent dump&lt;/td&gt;
&lt;td&gt;Dump completes; tables captured at different moments&lt;/td&gt;
&lt;td&gt;Restore fails or foreign keys break&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Truncated upload&lt;/td&gt;
&lt;td&gt;Network drop; multipart upload "completes" with a partial object&lt;/td&gt;
&lt;td&gt;Checksum mismatch or gzip error on read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong database&lt;/td&gt;
&lt;td&gt;Job points at a replica that stopped replicating weeks ago&lt;/td&gt;
&lt;td&gt;Freshness check: newest row is old&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing objects&lt;/td&gt;
&lt;td&gt;Views, routines, triggers or events excluded by default flags&lt;/td&gt;
&lt;td&gt;App queries fail after restore&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Encryption key lost&lt;/td&gt;
&lt;td&gt;Backups encrypted with a key nobody can find&lt;/td&gt;
&lt;td&gt;Drill cannot decrypt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retention deleted everything&lt;/td&gt;
&lt;td&gt;Lifecycle rule shorter than intended&lt;/td&gt;
&lt;td&gt;Drill finds no backup in the expected window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restore too slow&lt;/td&gt;
&lt;td&gt;400 GB dump takes 9 hours; RTO is 2 hours&lt;/td&gt;
&lt;td&gt;Measured restore duration vs target&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uploads not backed up&lt;/td&gt;
&lt;td&gt;Only the database is in scope; user files live on local disk&lt;/td&gt;
&lt;td&gt;File sample check fails&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is common in Laravel apps that store uploads on the &lt;code&gt;local&lt;/code&gt; disk. The database restores, and every invoice PDF link returns 404.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: take backups that can restore
&lt;/h2&gt;

&lt;p&gt;For MySQL with InnoDB, a consistent logical dump needs a transaction snapshot and the objects people forget:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mysqldump --single-transaction --quick --routines --triggers --events \
  --set-gtid-purged=OFF --hex-blob app_production \
  &lt;span class="o"&gt;|&lt;/span&gt; gzip -9 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"app_$(date -u +%Y%m%dT%H%MZ).sql.gz"&lt;/span&gt;
sha256sum app_*.sql.gz &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; SHA256SUMS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For PostgreSQL, the custom format is compressed, restorable in parallel and lets you restore selected tables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pg_dump --format=custom --no-owner --file=&lt;span class="s2"&gt;"app_$(date -u +%Y%m%dT%H%MZ).dump"&lt;/span&gt; app_production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For larger databases, logical dumps get slow. Physical backups with point-in-time recovery (Percona XtraBackup for MySQL, pgBackRest or WAL archiving for Postgres, or your cloud provider's snapshots plus binlog/WAL retention) restore much faster. The drill below works the same way regardless of method.&lt;/p&gt;

&lt;p&gt;Upload the checksum file next to the backup and verify it after upload by reading the object back, not by trusting the upload command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: the restore drill script
&lt;/h2&gt;

&lt;p&gt;This is a trimmed version of the drill we run for PostgreSQL clients. It restores into a disposable container, checks integrity and reports timings:&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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; -euo pipefail
&lt;span class="nv"&gt;BUCKET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;s3://acme-backups/postgres
&lt;span class="nv"&gt;WORK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;mktemp -d&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'docker rm -f drill-pg &amp;gt;/dev/null 2&amp;gt;&amp;amp;1; rm -rf "$WORK"'&lt;/span&gt; EXIT
&lt;span class="nv"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;date +%s&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;latest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;aws s3 ls &lt;span class="s2"&gt;"$BUCKET/"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; sort &lt;span class="o"&gt;|&lt;/span&gt; tail -1 &lt;span class="o"&gt;|&lt;/span&gt; awk &lt;span class="s1"&gt;'{print $4}'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
aws s3 cp &lt;span class="s2"&gt;"$BUCKET/$latest"&lt;/span&gt; &lt;span class="s2"&gt;"$WORK/db.dump"&lt;/span&gt; --only-show-errors
&lt;span class="nv"&gt;age_h&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$(&lt;/span&gt;date +%s&lt;span class="o"&gt;)&lt;/span&gt; - &lt;span class="nv"&gt;$(&lt;/span&gt;date -d &lt;span class="s2"&gt;"$(aws s3 ls "&lt;/span&gt;&lt;span class="nv"&gt;$BUCKET&lt;/span&gt;/&lt;span class="nv"&gt;$latest&lt;/span&gt;&lt;span class="s2"&gt;" | awk '{print $1"&lt;/span&gt; &lt;span class="s2"&gt;"$2}')"&lt;/span&gt; +%s&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;)&lt;/span&gt; / 3600 &lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"$age_h"&lt;/span&gt; -le 26 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL: newest backup is ${age_h}h old"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; 1&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

docker run -d --name drill-pg -e &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;drill -v &lt;span class="s2"&gt;"$WORK:/w"&lt;/span&gt; postgres:16 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;span class="k"&gt;until&lt;/span&gt; docker &lt;span class="nb"&gt;exec&lt;/span&gt; drill-pg pg_isready -q&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; sleep 1&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; drill-pg createdb -U postgres app
docker &lt;span class="nb"&gt;exec&lt;/span&gt; drill-pg pg_restore -U postgres -d app --no-owner --jobs=4 /w/db.dump

q&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; docker &lt;span class="nb"&gt;exec&lt;/span&gt; drill-pg psql -U postgres -d app -tAc &lt;span class="s2"&gt;"$1"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;users&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;q &lt;span class="s2"&gt;"select count(*) from users"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;newest&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;q &lt;span class="s2"&gt;"select extract(epoch from now() - max(created_at))/3600 from orders"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;fk_bad&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;q &lt;span class="s2"&gt;"select count(*) from order_items oi left join orders o on o.id = oi.order_id where o.id is null"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"$users"&lt;/span&gt; -gt 1000 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL: users=$users"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; 1&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"${newest%.*}"&lt;/span&gt; -le 30 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL: newest order ${newest}h old"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; 1&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"$fk_bad"&lt;/span&gt; -eq 0 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL: $fk_bad orphan order_items"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; 1&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"OK backup=$latest age=${age_h}h users=$users restore_s=$(( $(date +%s) - start ))"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three checks matter more than any others: &lt;strong&gt;freshness&lt;/strong&gt; (is the newest data recent enough to meet your RPO), &lt;strong&gt;plausibility&lt;/strong&gt; (row counts are in the expected range, not zero) and &lt;strong&gt;referential sanity&lt;/strong&gt; (no orphaned rows, which is what an inconsistent dump produces). Then add one or two queries your application really depends on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: schedule it and make silence an alert
&lt;/h2&gt;

&lt;p&gt;The drill is itself a cron job, which brings us back to Part 1: it needs a heartbeat. In Laravel, I schedule it weekly on a separate worker and ping a dead-man switch only on success:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// routes/console.php (Laravel 11+)&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate&lt;/span&gt;\&lt;span class="nc"&gt;Support&lt;/span&gt;\&lt;span class="nc"&gt;Facades&lt;/span&gt;\&lt;span class="nc"&gt;Schedule&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Schedule&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/opt/ops/restore-drill.sh'&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;weeklyOn&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="s1"&gt;'03:30'&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;onOneServer&lt;/span&gt;&lt;span class="p"&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;appendOutputTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;storage_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'logs/restore-drill.log'&lt;/span&gt;&lt;span class="p"&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;pingOnSuccess&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="s1"&gt;'DRILL_HEARTBEAT_URL'&lt;/span&gt;&lt;span class="p"&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;emailOutputOnFailure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ops@example.com'&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 the heartbeat does not arrive within the window, the monitoring service alerts. That covers the drill failing, the server being down and the schedule silently not running. (In real code, read the URL through &lt;code&gt;config()&lt;/code&gt; rather than &lt;code&gt;env()&lt;/code&gt;, for the reason covered in Part 4.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: track RTO and RPO as numbers
&lt;/h2&gt;

&lt;p&gt;Log every drill result: backup age, restore duration, database size. Plot them. Restore time grows with data, and the day it crosses your recovery time objective should be a planned conversation, not an incident discovery. On one client platform, restore time crept from 25 minutes to 2 hours 40 minutes in a year; we moved to physical backups with WAL archiving before it became a problem.&lt;/p&gt;

&lt;p&gt;Run a full human drill quarterly as well: a person follows the written runbook, end to end, including DNS, uploads, queues and cache warm-up. Automation verifies the data; the human drill verifies the process.&lt;/p&gt;

&lt;p&gt;This is a standard part of our &lt;a href="https://looporigin.com/services/server-setup-maintenance?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=backups-never-restored-restore-drills" rel="noopener noreferrer"&gt;server setup and maintenance&lt;/a&gt; work, and it is included in the maintenance tiers on our &lt;a href="https://looporigin.com/pricing?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=backups-never-restored-restore-drills" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt;, because an untested backup is a cost with no guaranteed benefit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnostic checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Dumps use &lt;code&gt;--single-transaction&lt;/code&gt; (MySQL) or a consistent method, and include routines, triggers and events.&lt;/li&gt;
&lt;li&gt;[ ] Checksums are verified after upload by reading the object back.&lt;/li&gt;
&lt;li&gt;[ ] User uploads and other files are backed up, not just the database.&lt;/li&gt;
&lt;li&gt;[ ] An automated restore drill runs at least weekly into a disposable database.&lt;/li&gt;
&lt;li&gt;[ ] The drill checks freshness, row-count plausibility and referential sanity.&lt;/li&gt;
&lt;li&gt;[ ] The drill has a heartbeat; silence alerts.&lt;/li&gt;
&lt;li&gt;[ ] Restore duration and backup age are logged and compared to RTO and RPO.&lt;/li&gt;
&lt;li&gt;[ ] Encryption keys for backups are stored and tested separately from the backups.&lt;/li&gt;
&lt;li&gt;[ ] Retention rules are reviewed and at least one older backup is restored per quarter.&lt;/li&gt;
&lt;li&gt;[ ] A human follows the restore runbook end to end every quarter.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Are managed database snapshots enough?
&lt;/h3&gt;

&lt;p&gt;They are a good base, but you should still restore one regularly into a new instance and run checks. Snapshots can be misconfigured, have short retention, or live in the same account an attacker or mistake can delete.&lt;/p&gt;

&lt;h3&gt;
  
  
  How often should restore drills run?
&lt;/h3&gt;

&lt;p&gt;Weekly for automated drills is a sensible default for most SaaS. Daily if your data changes fast and your RPO is tight.&lt;/p&gt;

&lt;h3&gt;
  
  
  Won't the drill cost a lot?
&lt;/h3&gt;

&lt;p&gt;A disposable container or a small instance for an hour a week is cheap compared to one failed restore. For very large databases, restore a recent subset daily and a full copy monthly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should the drill restore production data into a less secure environment?
&lt;/h3&gt;

&lt;p&gt;No. Run it in an isolated environment with the same access controls as production, and destroy it after the checks.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about point-in-time recovery?
&lt;/h3&gt;

&lt;p&gt;Drill it too: restore to a specific timestamp and check that a known row written just before that time exists and one written just after does not.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I back up Laravel storage files?
&lt;/h3&gt;

&lt;p&gt;Use an S3-compatible disk for uploads with versioning enabled, or sync the local disk to object storage on a schedule, and include a sample file check in the drill.&lt;/p&gt;

&lt;p&gt;Next and last in this series, Part 6 covers synthetic checks and SLOs for the paths nobody clicks.&lt;/p&gt;




&lt;h3&gt;
  
  
  Follow &amp;amp; Connect
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Website:&lt;/strong&gt; &lt;a href="https://looporigin.com/?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=backups-never-restored-restore-drills" rel="noopener noreferrer"&gt;Loop Origin&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/company/loop-origin" rel="noopener noreferrer"&gt;Loop Origin&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X:&lt;/strong&gt; &lt;a href="https://x.com/AliMughal887" rel="noopener noreferrer"&gt;Muhammad Ali Mughal (@AliMughal887)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/LoopOrigin" rel="noopener noreferrer"&gt;Loop Origin (@LoopOrigin)&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>database</category>
      <category>laravel</category>
      <category>sre</category>
    </item>
    <item>
      <title>Negotiating Level From Evidence: Mapping Proof to Staff vs Senior</title>
      <dc:creator>Muhammad Ali Mughal</dc:creator>
      <pubDate>Wed, 11 Nov 2026 07:10:19 +0000</pubDate>
      <link>https://dev.to/muhammadalimughal/negotiating-level-from-evidence-mapping-proof-to-staff-vs-senior-4c4n</link>
      <guid>https://dev.to/muhammadalimughal/negotiating-level-from-evidence-mapping-proof-to-staff-vs-senior-4c4n</guid>
      <description>&lt;p&gt;A few years ago I sat in a debrief where four interviewers agreed a candidate was "strong senior, maybe staff." The hiring manager asked one question: "What did they change that was bigger than their own team?" Nobody could answer from the notes. The candidate had done exactly that kind of work, a cross-team migration that removed a shared database from five services, but they had described it as "I worked on the database migration." The offer came in at senior. Two years later the same engineer got promoted to staff for work they had already done before they joined.&lt;/p&gt;

&lt;p&gt;I wrote this with help from AI tools and checked every template, rubric mapping and example myself. I am a Principal Software Engineer in Karachi with 9+ years across PHP/Laravel, Vue, Node and .NET microservices. I sit on hiring and levelling panels, and I build Smart Resume Analyzer. This is Part 6, the finale of the series Interview Proof Beyond the Resume. &lt;a href="https://dev.to/muhammadalimughal/take-home-tests-that-prove-depth-without-unpaid-labor-theater-2ak"&gt;Part 1 covered take-home tests&lt;/a&gt;, &lt;a href="https://dev.to/muhammadalimughal/system-design-interviews-from-production-scars-not-blog-diagrams-35gb"&gt;Part 3 covered system design from production scars&lt;/a&gt; and &lt;a href="https://dev.to/muhammadalimughal/async-video-written-interview-answers-that-sound-like-you-5acc"&gt;Part 5 covered async and written answers&lt;/a&gt;. All of that proof is wasted if it gets levelled one step too low.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;📚 Series Roadmap — Interview Proof Beyond the Resume&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/take-home-tests-that-prove-depth-without-unpaid-labor-theater-2ak"&gt;Take-Home Tests That Prove Depth Without Unpaid Labor Theater&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 2:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/live-coding-without-freezing-narration-checklists-partial-credit-j5o"&gt;Live Coding Without Freezing: Narration, Checklists &amp;amp; Partial Credit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/system-design-interviews-from-production-scars-not-blog-diagrams-35gb"&gt;System Design Interviews From Production Scars, Not Blog Diagrams&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 4:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/case-study-portfolios-that-humans-and-ats-both-parse-352e"&gt;Case-Study Portfolios That Humans and ATS Both Parse&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 5:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/async-video-written-interview-answers-that-sound-like-you-5acc"&gt;Async Video &amp;amp; Written Interview Answers That Sound Like You&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 6 (This Post):&lt;/strong&gt; Negotiating Level From Evidence: Mapping Proof to Staff vs Senior&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Related earlier posts:&lt;/em&gt; &lt;a href="https://dev.to/muhammadalimughal/engineering-leadership-bullets-mentoring-hiring-and-incident-command-without-fluff-h34"&gt;Engineering Leadership Bullets: Mentoring, Hiring &amp;amp; Incident Command&lt;/a&gt; · &lt;a href="https://dev.to/muhammadalimughal/tailoring-without-lying-mapping-job-description-keywords-to-real-evidence-2il6"&gt;Tailoring Without Lying&lt;/a&gt; · &lt;a href="https://dev.to/muhammadalimughal/the-one-story-resume-test-building-a-flagship-project-section-that-still-passes-ats-28co"&gt;The One-Story Resume Test&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How do you negotiate a staff level instead of senior using evidence?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Direct answer:&lt;/strong&gt; Levels are decided by scope, not years. Before the loop, map two or three of your strongest stories to the company's levelling dimensions: scope of impact, ambiguity handled, technical direction set and people multiplied. Say the scope out loud in interviews, then in the offer conversation ask which dimension fell short and offer the specific story that covers it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fliwq0t4s23raqpshqr9f.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fliwq0t4s23raqpshqr9f.jpg" alt="Warm oak staircase rising toward a bright office window, AI-generated" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why levelling goes wrong for good engineers
&lt;/h2&gt;

&lt;p&gt;Most companies decide level in a debrief or a hiring committee, from interviewer notes. Your compensation band follows from that decision, and recruiters usually have far more room to move within a band than to move you between bands. That makes levelling the highest-leverage negotiation you will have, and it mostly happens before you ever see an offer.&lt;/p&gt;

&lt;p&gt;Three patterns push people down a level:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"We" language.&lt;/strong&gt; Interviewers cannot credit scope they cannot attribute to you. "We moved to event-driven billing" scores as team participation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Depth without breadth.&lt;/strong&gt; A brilliant deep-dive on a cache bug reads as strong senior. Staff panels look for problems that crossed team boundaries or lasted quarters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No decision trail.&lt;/strong&gt; Staff work is mostly choosing between reasonable options and getting others to commit. If your story skips the trade-off and the alignment, it sounds like execution.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What senior and staff evidence actually look like
&lt;/h2&gt;

&lt;p&gt;Every company's rubric is worded differently, but the dimensions are remarkably consistent. This is the mapping I use when coaching engineers, built from the levelling guides I have worked with:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Senior evidence&lt;/th&gt;
&lt;th&gt;Staff evidence&lt;/th&gt;
&lt;th&gt;Story phrase that signals it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Scope&lt;/td&gt;
&lt;td&gt;Owns a service or feature area end to end&lt;/td&gt;
&lt;td&gt;Owns a problem across several teams or systems&lt;/td&gt;
&lt;td&gt;"Five services depended on it, none owned it"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ambiguity&lt;/td&gt;
&lt;td&gt;Turns a defined problem into a plan&lt;/td&gt;
&lt;td&gt;Defines the problem when nobody agreed there was one&lt;/td&gt;
&lt;td&gt;"I wrote the doc that made it a priority"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Technical direction&lt;/td&gt;
&lt;td&gt;Picks good designs inside a team&lt;/td&gt;
&lt;td&gt;Sets patterns other teams adopt&lt;/td&gt;
&lt;td&gt;"Three teams later used the same approach"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiplying others&lt;/td&gt;
&lt;td&gt;Mentors individuals, reviews well&lt;/td&gt;
&lt;td&gt;Changes how a group works: standards, tooling, process&lt;/td&gt;
&lt;td&gt;"Onboarding dropped from three weeks to one"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time horizon&lt;/td&gt;
&lt;td&gt;Delivers this quarter&lt;/td&gt;
&lt;td&gt;Plans and lands multi-quarter work&lt;/td&gt;
&lt;td&gt;"Phased over two quarters with a rollback plan"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Risk handled&lt;/td&gt;
&lt;td&gt;Handles incidents in own area&lt;/td&gt;
&lt;td&gt;Removes a class of incidents&lt;/td&gt;
&lt;td&gt;"We have not had a migration outage since"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice the right-hand column. You do not need a staff title to have staff evidence. You need stories where the scope is stated, not implied.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: build an evidence map before the loop
&lt;/h2&gt;

&lt;p&gt;I ask engineers to fill a simple table for their top three stories. It takes an hour and changes how every interview goes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;STORY: Shared billing database split
Scope:        5 services, 3 teams, 1 shared Postgres instance
Ambiguity:    No owner; outages blamed on "the DB"; I wrote the proposal
Direction:    Expand/contract migration pattern + contract tests
Multiplying:  Ran 2 workshops; pattern reused by 2 other teams
Horizon:      2 quarters, 4 phases, each reversible
Risk removed: Zero DB-related Sev1s in the 9 months after
My decisions: Chose per-service schemas over full separate DBs (cost)
What I'd change: Should have involved finance earlier on reporting
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every row is something an interviewer can write in their notes verbatim. The last two rows matter more than people expect: senior and staff panels both probe for judgement and honesty about trade-offs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: put the scope on the resume, not only in the room
&lt;/h2&gt;

&lt;p&gt;The resume sets the level expectation before you speak. If your bullets read like senior work, interviewers calibrate to senior and you spend the loop trying to climb out. Compare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before:
- Worked on database migration to improve reliability.

After:
- Led the split of a shared billing database used by 5 services across
  3 teams; designed a 4-phase expand/contract plan adopted as the
  org-wide migration pattern; zero DB-related Sev1s in the next 9 months.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "after" version also contains the keywords a staff job description tends to use: led, across teams, designed, adopted, org-wide. When I want to check whether my resume matches the scope language of a specific posting, I run it against the description in the &lt;a href="https://smartresumeanalyzer.com/resume-job-match?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=negotiate-level-from-evidence-staff-vs-senior" rel="noopener noreferrer"&gt;Smart Resume Analyzer job match tool&lt;/a&gt; and look for missing scope words, not just missing technologies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: say the scope in the interview
&lt;/h2&gt;

&lt;p&gt;Interviewers write fast. Give them sentences that map to the rubric. A pattern that works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Context in one breath:&lt;/strong&gt; "Five services shared one billing database and nobody owned it."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your decision:&lt;/strong&gt; "I proposed splitting it by schema rather than by database, because full separation tripled our hosting cost."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How you got commitment:&lt;/strong&gt; "I wrote the doc, ran it past each team lead, and changed the plan after finance pushed back."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outcome with a number:&lt;/strong&gt; "Four reversible phases over two quarters; no DB Sev1s for nine months."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What it multiplied:&lt;/strong&gt; "Two other teams reused the playbook."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the interviewer is only asking about code, find a natural place to add the scope line anyway. "This fix ended up becoming the pattern for the other teams" takes five seconds and changes the note.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: negotiate the level, then the number
&lt;/h2&gt;

&lt;p&gt;When the recruiter calls with an offer, ask about the level before the money. These are the questions I suggest, and they are polite enough for any company:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"What level is this offer at, and what does the next level up look for?"&lt;/li&gt;
&lt;li&gt;"Was there a specific dimension where the panel felt the evidence was light?"&lt;/li&gt;
&lt;li&gt;"If I can share a concrete example for that dimension, is the level open to review?"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then send a short written follow-up with one story mapped to that dimension, in the same format as the evidence map. Keep it to 150 words. Hiring committees can and do revisit levels when given new evidence, but rarely when given only a counter-offer number.&lt;/p&gt;

&lt;p&gt;If they will not move the level, ask for a written review point: "Can we agree on a levelling review at six months against these criteria?" That turns a no into a plan. Also check which skills the target level expects that you have not yet shown; the &lt;a href="https://smartresumeanalyzer.com/tools/skill-gap-analyzer?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=negotiate-level-from-evidence-staff-vs-senior" rel="noopener noreferrer"&gt;skill gap analyzer&lt;/a&gt; is a quick way to list gaps against the posting so your six-month plan is specific.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnostic checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] I have three stories mapped to scope, ambiguity, direction, multiplying, horizon and risk.&lt;/li&gt;
&lt;li&gt;[ ] Each story names how many teams, services or people were affected.&lt;/li&gt;
&lt;li&gt;[ ] Each story includes a decision I made and a trade-off I rejected.&lt;/li&gt;
&lt;li&gt;[ ] My resume bullets state scope with numbers, not just technologies.&lt;/li&gt;
&lt;li&gt;[ ] I use "I" for my decisions and "we" only for team outcomes.&lt;/li&gt;
&lt;li&gt;[ ] I can tell each story in under two minutes, with the scope in the first sentence.&lt;/li&gt;
&lt;li&gt;[ ] I will ask about level before compensation on the offer call.&lt;/li&gt;
&lt;li&gt;[ ] I have a 150-word follow-up ready for the dimension most likely to be questioned.&lt;/li&gt;
&lt;li&gt;[ ] If the level does not move, I will ask for a written review point.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Is it risky to ask for a higher level?
&lt;/h3&gt;

&lt;p&gt;Not if you ask with evidence and ask what the gap was. Asking "why" is normal; demanding a title without evidence is what goes badly.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if my current title is senior but my work was staff scope?
&lt;/h3&gt;

&lt;p&gt;Titles vary wildly between companies. Describe the scope plainly and let the evidence speak. Many panels expect this and level on the work, not the title.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do years of experience matter for staff?
&lt;/h3&gt;

&lt;p&gt;Less than scope. Some companies use years as a soft guide, but a strong cross-team story usually outweighs an extra two years of single-team work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I mention competing offers at a higher level?
&lt;/h3&gt;

&lt;p&gt;You can, factually and once. It works best combined with evidence, because the committee still needs a reason that holds up internally.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I show "multiplying others" if I was never a manager?
&lt;/h3&gt;

&lt;p&gt;Standards you wrote, tooling you built, onboarding you improved, reviews that changed practices, incidents you led. These all count as staff-level multiplication.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if the company has no public levelling guide?
&lt;/h3&gt;

&lt;p&gt;Ask the recruiter for the level expectations. If they cannot share them, use the six dimensions in the table above; they map closely to most guides I have seen.&lt;/p&gt;

&lt;p&gt;This finale closes Interview Proof Beyond the Resume. Across six parts we covered take-homes, live coding, system design, portfolios, async answers and now levelling: the common thread is that interviewers reward evidence they can write down. Make your scope easy to write down.&lt;/p&gt;




&lt;h3&gt;
  
  
  Follow &amp;amp; Connect
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Website:&lt;/strong&gt; &lt;a href="https://smartresumeanalyzer.com/?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=negotiate-level-from-evidence-staff-vs-senior" rel="noopener noreferrer"&gt;Smart Resume Analyzer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;YouTube:&lt;/strong&gt; &lt;a href="https://www.youtube.com/@resume-boost-ai" rel="noopener noreferrer"&gt;Resume Boost AI (@resume-boost-ai)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TikTok:&lt;/strong&gt; &lt;a href="https://www.tiktok.com/@resumeboostai" rel="noopener noreferrer"&gt;Resume Boost AI (@resumeboostai)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X:&lt;/strong&gt; &lt;a href="https://x.com/AliMughal887" rel="noopener noreferrer"&gt;Muhammad Ali Mughal (@AliMughal887)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ali111887" rel="noopener noreferrer"&gt;Muhammad Ali Mughal (@ali111887)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/muhammad-ali-m-03434a120/" rel="noopener noreferrer"&gt;Muhammad Ali Mughal&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>career</category>
      <category>interview</category>
      <category>resume</category>
      <category>leadership</category>
    </item>
    <item>
      <title>Config &amp; Feature Flags That Quietly Diverged Across Environments</title>
      <dc:creator>Muhammad Ali Mughal</dc:creator>
      <pubDate>Tue, 10 Nov 2026 16:15:01 +0000</pubDate>
      <link>https://dev.to/muhammadalimughal/config-feature-flags-that-quietly-diverged-across-environments-4m38</link>
      <guid>https://dev.to/muhammadalimughal/config-feature-flags-that-quietly-diverged-across-environments-4m38</guid>
      <description>&lt;p&gt;We shipped a new invoicing flow on a Tuesday. It had passed three weeks of QA on staging, with automated tests and a client sign-off. Within an hour of release, production invoices for one tenant group were rounding tax differently from the PDFs they had approved. The code was identical. The difference was a feature flag: on staging, &lt;code&gt;tax-engine-v2&lt;/code&gt; had been switched on for everyone months earlier by a developer testing something else, and nobody turned it off. On production it was still off for most tenants. QA had been testing a code path that production customers had never run, and the old path had a rounding bug that only appeared with the new invoice layout.&lt;/p&gt;

&lt;p&gt;I wrote this with help from AI tools and checked every config sample, command and code snippet myself. I am a Principal Software Engineer in Karachi with 9+ years across PHP/Laravel, Vue, Node and .NET microservices, and at Loop Origin we build and maintain Laravel SaaS platforms where staging and production must behave the same way to mean anything. This is Part 4 of the series Silent Failures in Production SaaS. &lt;a href="https://dev.to/muhammadalimughal/cron-jobs-that-never-ran-heartbeats-dead-man-switches-for-saas-1gk6"&gt;Part 1 covered cron jobs that never ran&lt;/a&gt;, &lt;a href="https://dev.to/muhammadalimughal/webhooks-that-stopped-arriving-gap-detection-reconciliation-jo7"&gt;Part 2 covered webhooks that stopped arriving&lt;/a&gt; and &lt;a href="https://dev.to/muhammadalimughal/email-that-never-landed-spfdkimdmarc-drift-bounce-pipelines-999"&gt;Part 3 covered email that never landed&lt;/a&gt;. Config drift is the quietest of them all: nothing fails, the environments just stop being comparable.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;📚 Series Roadmap — Silent Failures in Production SaaS&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/cron-jobs-that-never-ran-heartbeats-dead-man-switches-for-saas-1gk6"&gt;Cron Jobs That Never Ran: Heartbeats &amp;amp; Dead-Man Switches for SaaS&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 2:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/webhooks-that-stopped-arriving-gap-detection-reconciliation-jo7"&gt;Webhooks That Stopped Arriving: Gap Detection &amp;amp; Reconciliation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/email-that-never-landed-spfdkimdmarc-drift-bounce-pipelines-999"&gt;Email That Never Landed: SPF/DKIM/DMARC Drift &amp;amp; Bounce Pipelines&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 4 (This Post):&lt;/strong&gt; Config &amp;amp; Feature Flags That Quietly Diverged Across Environments&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 5:&lt;/strong&gt; Backups That Never Restored: Automated Restore Drills&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 6:&lt;/strong&gt; Synthetic Checks &amp;amp; SLOs for the Paths Nobody Clicks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Related earlier posts:&lt;/em&gt; &lt;a href="https://dev.to/muhammadalimughal/zero-downtime-database-migrations-in-laravel-nodejs-saas-expand-contract-lock-timeouts-6lp"&gt;Zero-Downtime Database Migrations in Laravel &amp;amp; Node.js SaaS&lt;/a&gt; · &lt;a href="https://dev.to/muhammadalimughal/delayed-jobs-schedulers-clock-skew-gotchas-253h"&gt;Delayed Jobs, Schedulers &amp;amp; Clock Skew Gotchas&lt;/a&gt; · &lt;a href="https://dev.to/muhammadalimughal/production-observability-on-a-budget-opentelemetry-traces-structured-logs-tail-sampling-360a"&gt;Production Observability on a Budget&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How do you detect config and feature flag drift between staging and production?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Direct answer:&lt;/strong&gt; Define which config keys and flags must match across environments, publish a redacted fingerprint of their effective values from each environment, and diff them automatically on every deploy and on a schedule. Check environment variable keys against a template in CI. Give every feature flag an owner and expiry date, and alert on flags whose state differs without a recorded reason.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk4u7qy6bqlj6ugqqrj7y.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk4u7qy6bqlj6ugqqrj7y.jpg" alt="Railway tracks running through a misty green valley at dawn, AI-generated" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why drift does not look like a failure
&lt;/h2&gt;

&lt;p&gt;Config drift rarely breaks anything on the day it happens. Someone raises a queue timeout on production during an incident and forgets to update the infrastructure repo. Someone adds a variable to staging's &lt;code&gt;.env&lt;/code&gt; by hand. A flag is flipped for a demo. Each change is reasonable in the moment. Weeks later, the cost shows up in three ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tests stop predicting production.&lt;/strong&gt; Staging passes because it runs different code or different limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incidents take longer.&lt;/strong&gt; The first hour goes on "but it works on staging" instead of the actual cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rollbacks do not roll back.&lt;/strong&gt; Reverting code does not revert a hand-edited value on one server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here are the drift types I see most often in Laravel and Node SaaS stacks:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Drift type&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Detection&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Missing env key&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;STRIPE_WEBHOOK_SECRET&lt;/code&gt; set on staging only&lt;/td&gt;
&lt;td&gt;Feature works in QA, silently disabled in prod&lt;/td&gt;
&lt;td&gt;Key parity check against &lt;code&gt;.env.example&lt;/code&gt; in CI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Different value&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;retry_after&lt;/code&gt; 90 in prod, 600 on staging&lt;/td&gt;
&lt;td&gt;Duplicate long-running jobs only in prod&lt;/td&gt;
&lt;td&gt;Config fingerprint diff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stale config cache&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;config:cache&lt;/code&gt; not re-run after env change&lt;/td&gt;
&lt;td&gt;New value ignored until next deploy&lt;/td&gt;
&lt;td&gt;Fingerprint after deploy vs expected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;env()&lt;/code&gt; outside config files&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;env('FEATURE_X')&lt;/code&gt; in a controller&lt;/td&gt;
&lt;td&gt;Returns &lt;code&gt;null&lt;/code&gt; in prod once config is cached&lt;/td&gt;
&lt;td&gt;Static grep in CI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flag state divergence&lt;/td&gt;
&lt;td&gt;Flag on for all in staging, 5% in prod&lt;/td&gt;
&lt;td&gt;QA tests a path customers do not run&lt;/td&gt;
&lt;td&gt;Flag state diff with owners&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-server divergence&lt;/td&gt;
&lt;td&gt;One worker host has an old &lt;code&gt;.env&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Intermittent behaviour by host&lt;/td&gt;
&lt;td&gt;Fingerprint per host, not per environment&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Laravel trap that bites everyone once
&lt;/h2&gt;

&lt;p&gt;Laravel's docs are explicit about this one. After you run &lt;code&gt;php artisan config:cache&lt;/code&gt;, the &lt;code&gt;.env&lt;/code&gt; file is not loaded, and calls to &lt;code&gt;env()&lt;/code&gt; outside of config files return &lt;code&gt;null&lt;/code&gt;. Locally nobody caches config, so the code works. In production, it quietly takes the default branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wrong: works locally, returns null in production after config:cache&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;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'INVOICE_V2_ENABLED'&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="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Right: read env only inside config files&lt;/span&gt;
&lt;span class="c1"&gt;// config/invoicing.php&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'v2_enabled'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&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="s1"&gt;'INVOICE_V2_ENABLED'&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="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// app code&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;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'invoicing.v2_enabled'&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="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A one-line CI guard catches regressions:&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;# Fail the build if env() called outside config/&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; grep -rnE &lt;span class="s1"&gt;'\benv\('&lt;/span&gt; app/ routes/ resources/views/ --include=&lt;span class="s1"&gt;'*.php'&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"env() used outside config/. Use config() instead."&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit&lt;/span&gt; 1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Key parity: every environment declares the same variables
&lt;/h2&gt;

&lt;p&gt;Values should differ between environments. The set of keys usually should not. Treat &lt;code&gt;.env.example&lt;/code&gt; as the contract and check it in CI and on each server at deploy:&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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# env-parity.sh: compare keys in .env.example with the live environment file&lt;/span&gt;
keys&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; grep -E &lt;span class="s1"&gt;'^[A-Z0-9_]+='&lt;/span&gt; &lt;span class="s2"&gt;"$1"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; cut -d= -f1 &lt;span class="o"&gt;|&lt;/span&gt; sort -u&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;missing&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;comm -23 &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;keys .env.example&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;keys &lt;span class="s2"&gt;"${1:-.env}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;extra&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;comm -13 &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;keys .env.example&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;keys &lt;span class="s2"&gt;"${1:-.env}"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="o"&gt;[&lt;/span&gt; -n &lt;span class="s2"&gt;"$missing"&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="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Missing keys:"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"$missing"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; -n &lt;span class="s2"&gt;"$extra"&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="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Undeclared keys:"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"$extra"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; -z &lt;span class="s2"&gt;"$missing$extra"&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="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"env parity OK"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; -z &lt;span class="s2"&gt;"$missing"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Undeclared keys are not always errors, but each one is something a new environment will not have.&lt;/p&gt;

&lt;h2&gt;
  
  
  A redacted config fingerprint per host
&lt;/h2&gt;

&lt;p&gt;The most useful control I have added to client platforms is a small command that prints the effective values of the keys we care about, with secrets reduced to "set" or "missing". Because it reads &lt;code&gt;config()&lt;/code&gt;, it reflects the cached config the app actually uses, not the &lt;code&gt;.env&lt;/code&gt; file on disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Console/Commands/ConfigFingerprint.php&lt;/span&gt;
&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nc"&gt;App&lt;/span&gt;\&lt;span class="nc"&gt;Console&lt;/span&gt;\&lt;span class="nc"&gt;Commands&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Illuminate&lt;/span&gt;\&lt;span class="nc"&gt;Console&lt;/span&gt;\&lt;span class="nc"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Laravel&lt;/span&gt;\&lt;span class="nc"&gt;Pennant&lt;/span&gt;\&lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConfigFingerprint&lt;/span&gt; &lt;span class="k"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Command&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ops:config-fingerprint'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Print redacted effective config and flag state as JSON'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$tracked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'app.env'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'app.debug'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cache.default'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'queue.default'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'queue.connections.redis.retry_after'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'session.secure'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'session.lifetime'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'mail.default'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'services.stripe.webhook_secret'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'invoicing.v2_enabled'&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;private&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'services.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;private&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$flags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'tax-engine-v2'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'new-checkout'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handle&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="kt"&gt;int&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'host'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;gethostname&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="s1"&gt;'release'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.release'&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;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tracked&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'config'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;secret&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="o"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$v&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'set'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'missing'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$v&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;flags&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$flag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'flags'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$flag&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Feature&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;for&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$flag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// global scope&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;line&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;json_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;JSON_PRETTY_PRINT&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="no"&gt;JSON_UNESCAPED_SLASHES&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;return&lt;/span&gt; &lt;span class="kt"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SUCCESS&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;Run it on every host after deploy, store the JSON as a build artefact, and diff:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh app@staging-web-1 &lt;span class="s1"&gt;'cd /var/www/app &amp;amp;&amp;amp; php artisan ops:config-fingerprint'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; staging.json
ssh app@prod-web-1    &lt;span class="s1"&gt;'cd /var/www/app &amp;amp;&amp;amp; php artisan ops:config-fingerprint'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; prod.json
diff &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;jq -S &lt;span class="s1"&gt;'del(.host, .release)'&lt;/span&gt; staging.json&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;jq -S &lt;span class="s1"&gt;'del(.host, .release)'&lt;/span&gt; prod.json&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some keys are supposed to differ, such as &lt;code&gt;app.env&lt;/code&gt;. Keep an allowlist of expected differences in the repo, and alert on anything else. Comparing hosts within one environment matters just as much: a worker server with an old &lt;code&gt;.env&lt;/code&gt; produces the "only sometimes" bugs that are hardest to trace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature flags need an owner and an expiry
&lt;/h2&gt;

&lt;p&gt;Laravel Pennant, LaunchDarkly, Unleash or a homegrown table all share the same failure mode: flags outlive their purpose and their state stops being deliberate. I keep a flag registry in the repo:&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="c1"&gt;# flags.yaml
&lt;/span&gt;&lt;span class="p"&gt;-&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;tax-engine-v2&lt;/span&gt;
  &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;billing-team&lt;/span&gt;
  &lt;span class="na"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2026-09-14&lt;/span&gt;
  &lt;span class="na"&gt;expires&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2026-11-30&lt;/span&gt;
  &lt;span class="na"&gt;intended&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;staging&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;rollout:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;production&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;rollout:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Gradual rollout; staging at 100% for QA of v2 only"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A scheduled job compares the registry's intended state with the fingerprint output and flags three things: state that differs from &lt;code&gt;intended&lt;/code&gt;, flags past &lt;code&gt;expires&lt;/code&gt;, and flags present in code but missing from the registry. With Pennant's database driver, remember that resolved values are stored per scope, so changing the definition does not change already-stored values until you purge them with &lt;code&gt;php artisan pennant:purge&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Most importantly, QA should test the state production will have after release. If staging is at 100% and production will be at 10%, test both paths.&lt;/p&gt;

&lt;p&gt;When we take on maintenance of an existing platform as part of our &lt;a href="https://looporigin.com/services/laravel-development?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=config-feature-flags-environment-drift" rel="noopener noreferrer"&gt;Laravel development&lt;/a&gt; work, a fingerprint and flag audit is one of the first things we run, because it explains a surprising number of "random" bugs. For multi-service systems we build through &lt;a href="https://looporigin.com/services/custom-web-application-development?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=config-feature-flags-environment-drift" rel="noopener noreferrer"&gt;custom web application development&lt;/a&gt;, the same fingerprint idea applies per service, with one combined diff on the deploy dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnostic checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;code&gt;.env.example&lt;/code&gt; lists every key, and CI checks key parity for each environment.&lt;/li&gt;
&lt;li&gt;[ ] No &lt;code&gt;env()&lt;/code&gt; calls outside &lt;code&gt;config/&lt;/code&gt;; CI enforces it.&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;config:cache&lt;/code&gt; runs on every deploy after env changes, never by hand only.&lt;/li&gt;
&lt;li&gt;[ ] A redacted config fingerprint is produced per host after each deploy.&lt;/li&gt;
&lt;li&gt;[ ] Staging vs production fingerprints are diffed with an allowlist of expected differences.&lt;/li&gt;
&lt;li&gt;[ ] Hosts within one environment are compared to each other.&lt;/li&gt;
&lt;li&gt;[ ] Every flag has an owner, intended state per environment and an expiry date.&lt;/li&gt;
&lt;li&gt;[ ] Flags in code but not in the registry fail CI.&lt;/li&gt;
&lt;li&gt;[ ] QA tests the flag state production will have after release.&lt;/li&gt;
&lt;li&gt;[ ] Hotfix config changes made during incidents are back-ported to the infra repo within a day.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Should staging and production have identical config?
&lt;/h3&gt;

&lt;p&gt;No. Credentials, hostnames and scale differ. What should match is the set of keys and the behaviour-changing values such as timeouts, limits and flags, unless a difference is documented.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is infrastructure as code enough to prevent drift?
&lt;/h3&gt;

&lt;p&gt;It reduces it a lot, but hand edits during incidents, manual flag changes and stale caches still happen. You need detection as well as declaration.&lt;/p&gt;

&lt;h3&gt;
  
  
  How often should the fingerprint diff run?
&lt;/h3&gt;

&lt;p&gt;On every deploy, plus a scheduled run, for example hourly or daily, to catch changes made outside the pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I expose the fingerprint over HTTP?
&lt;/h3&gt;

&lt;p&gt;Only on an internal or authenticated endpoint, and only with secrets redacted. Config values reveal a lot about your infrastructure.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about Node services?
&lt;/h3&gt;

&lt;p&gt;The same pattern works: load config once into a typed object at startup, print a redacted JSON fingerprint, and diff it the same way. Libraries such as zod or envalid help validate keys at boot.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I clean up old feature flags?
&lt;/h3&gt;

&lt;p&gt;Use the expiry date in the registry. When a flag expires, either make the winning path permanent and delete the flag, or renew it with a new reason.&lt;/p&gt;

&lt;p&gt;Next in this series, Part 5 covers backups that never restored and how to automate restore drills.&lt;/p&gt;




&lt;h3&gt;
  
  
  Follow &amp;amp; Connect
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Website:&lt;/strong&gt; &lt;a href="https://looporigin.com/?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=config-feature-flags-environment-drift" rel="noopener noreferrer"&gt;Loop Origin&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/company/loop-origin" rel="noopener noreferrer"&gt;Loop Origin&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X:&lt;/strong&gt; &lt;a href="https://x.com/AliMughal887" rel="noopener noreferrer"&gt;Muhammad Ali Mughal (@AliMughal887)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/LoopOrigin" rel="noopener noreferrer"&gt;Loop Origin (@LoopOrigin)&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>laravel</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Extension &amp; npm Postinstall Forensics: Homoglyphs, Base64 &amp; Unexpected Network</title>
      <dc:creator>Muhammad Ali Mughal</dc:creator>
      <pubDate>Tue, 10 Nov 2026 16:12:12 +0000</pubDate>
      <link>https://dev.to/muhammadalimughal/extension-npm-postinstall-forensics-homoglyphs-base64-unexpected-network-2g6j</link>
      <guid>https://dev.to/muhammadalimughal/extension-npm-postinstall-forensics-homoglyphs-base64-unexpected-network-2g6j</guid>
      <description>&lt;p&gt;A few months ago our CI egress firewall blocked an outbound HTTPS connection during &lt;code&gt;npm ci&lt;/code&gt; on a routine dependency bump. The build still passed, because the request failed quietly and the install moved on. The connection came from a &lt;code&gt;postinstall&lt;/code&gt; script in a transitive dependency four levels deep, a small utility none of us had heard of. Its new patch release added a single line that ran &lt;code&gt;node ./lib/setup.js&lt;/code&gt;. That file contained a 9 KB Base64 string, decoded at runtime, which tried to read environment variables and post them to a host we had never seen. Without the firewall, nothing in our pipeline would have noticed.&lt;/p&gt;

&lt;p&gt;I wrote this with help from AI tools and checked every command, script and manifest field myself. I am a Principal Software Engineer in Karachi with 9+ years across PHP/Laravel, Vue, Node and .NET, and I build PixelValut, a set of browser-side developer tools. This is Part 5 of the series Client Trust &amp;amp; Agent Hijack Defense. &lt;a href="https://dev.to/muhammadalimughal/prompt-injection-via-docs-your-tools-fetch-markdown-readmes-issue-bodies-bpg"&gt;Part 1 covered prompt injection in fetched docs&lt;/a&gt;, &lt;a href="https://dev.to/muhammadalimughal/webmcp-tool-calling-attack-surfaces-what-an-e-shop-can-make-an-agent-do-2981"&gt;Part 2 covered tool-calling attack surfaces&lt;/a&gt;, &lt;a href="https://dev.to/muhammadalimughal/subresource-integrity-cdn-script-pinning-catching-silent-payload-swaps-1654"&gt;Part 3 covered SRI and CDN pinning&lt;/a&gt; and &lt;a href="https://dev.to/muhammadalimughal/csp-trusted-types-residual-xss-reading-reports-like-a-forensics-log-h4g"&gt;Part 4 covered CSP and Trusted Types reports&lt;/a&gt;. Those parts defended the page. This one goes upstream, to the code that runs on developer machines and inside browsers before your policies ever apply.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;📚 Series Roadmap — Client Trust &amp;amp; Agent Hijack Defense&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/prompt-injection-via-docs-your-tools-fetch-markdown-readmes-issue-bodies-bpg"&gt;Prompt Injection via Docs Your Tools Fetch&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 2:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/webmcp-tool-calling-attack-surfaces-what-an-e-shop-can-make-an-agent-do-2981"&gt;WebMCP &amp;amp; Tool-Calling Attack Surfaces&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/subresource-integrity-cdn-script-pinning-catching-silent-payload-swaps-1654"&gt;Subresource Integrity &amp;amp; CDN Script Pinning&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 4:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/csp-trusted-types-residual-xss-reading-reports-like-a-forensics-log-h4g"&gt;CSP, Trusted Types &amp;amp; Residual XSS&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 5 (This Post):&lt;/strong&gt; Extension &amp;amp; npm Postinstall Forensics: Homoglyphs, Base64 &amp;amp; Unexpected Network&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 6:&lt;/strong&gt; Browser Secrets That Leak: Tokens, Storage Quotas &amp;amp; Exfil Paths&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Related earlier posts:&lt;/em&gt; &lt;a href="https://dev.to/muhammadalimughal/deobfuscating-malicious-npm-browser-payloads-base64-hex-unicode-escapes-homoglyphs-39eb"&gt;Deobfuscating Malicious npm &amp;amp; Browser Payloads&lt;/a&gt; · &lt;a href="https://dev.to/muhammadalimughal/regular-expression-denial-of-service-redos-in-js-php-spotting-catastrophic-backtracking-3bfd"&gt;ReDoS in JS &amp;amp; PHP: Spotting Catastrophic Backtracking&lt;/a&gt; · &lt;a href="https://dev.to/muhammadalimughal/webhook-security-in-production-hmac-sha256-signatures-timestamp-drift-replay-protection-4f8a"&gt;Webhook Security in Production&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How do you investigate a suspicious npm postinstall script or browser extension?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Direct answer:&lt;/strong&gt; Install with lifecycle scripts disabled, list every package that declares install scripts, and diff those scripts against the previous version. Scan the code for long Base64 or hex blobs, invisible or mixed-script Unicode, and dynamic evaluation. Then run the install or extension in a sandbox with network logging and treat any unexpected outbound connection as an incident.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8atfqmo9nlwq11xuqidc.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8atfqmo9nlwq11xuqidc.jpg" alt="Plain kraft paper parcel tied with twine on a dark concrete surface, AI-generated" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why install-time code is the soft spot
&lt;/h2&gt;

&lt;p&gt;npm runs &lt;code&gt;preinstall&lt;/code&gt;, &lt;code&gt;install&lt;/code&gt; and &lt;code&gt;postinstall&lt;/code&gt; scripts from dependencies by default, with your user's permissions, your environment variables and your network. That is how native modules compile, and it is also how several well-known supply-chain incidents delivered payloads, including the hijacked ua-parser-js releases in 2021, which used an install script to fetch and run malware. Browser extensions have a similar shape: once installed, a content script with broad host permissions can read and modify every page you open, including your admin panels.&lt;/p&gt;

&lt;p&gt;Both are trusted by default, both update silently, and both run before your CSP, SRI or code review gets a say.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: stop scripts from running while you look
&lt;/h2&gt;

&lt;p&gt;Make "no scripts" the default and opt packages in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .npmrc (project or CI)
&lt;/span&gt;&lt;span class="na"&gt;ignore-scripts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm ci --ignore-scripts
npm rebuild esbuild sharp   &lt;span class="c"&gt;# explicitly run scripts only for packages you trust&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;pnpm (v10 and later) does not run dependency build scripts unless the package is allowlisted, and Bun similarly requires &lt;code&gt;trustedDependencies&lt;/code&gt;. Check your package manager version's documentation, because these defaults have changed over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: find every package that wants to run code
&lt;/h2&gt;

&lt;p&gt;This script walks &lt;code&gt;node_modules&lt;/code&gt; and prints each package with lifecycle scripts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// list-install-scripts.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;existsSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'node:fs'&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="n"&gt;join&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'node:path'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;HOOKS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'preinstall'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'install'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'postinstall'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'prepare'&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="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;pkgs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir&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="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir&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="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="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&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="s1"&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;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&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="n"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&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="n"&gt;name&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="s1"&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="k"&gt;yield&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;pkgs&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="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;continue&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="n"&gt;pj&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="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'package.json'&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="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;yield&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="nb"&gt;JSON&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="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'utf8'&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="k"&gt;yield&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;pkgs&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="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'node_modules'&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="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="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pj&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;pkgs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'node_modules'&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="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;HOOKS&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scripts&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="n"&gt;h&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&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="sb"&gt;`${pj.name}@${pj.version}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hits&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="sb"&gt;`${h}: ${pj.scripts[h]}`&lt;/span&gt;&lt;span class="p"&gt;)&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="s1"&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;Run it after every lockfile change and diff the output in the PR. A package that gains an install script in a patch release deserves a human look. Also note that a &lt;code&gt;binding.gyp&lt;/code&gt; file without an explicit install script still makes npm run &lt;code&gt;node-gyp rebuild&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To compare two published versions without installing either:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm pack some-util@1.4.2 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm pack some-util@1.4.3
mkdir a b &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; tar -xzf some-util-1.4.2.tgz -C a &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; tar -xzf some-util-1.4.3.tgz -C b
diff -ru a/package b/package &lt;span class="o"&gt;|&lt;/span&gt; less
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: hunt for obfuscation signals
&lt;/h2&gt;

&lt;p&gt;Attackers hide payloads from reviewers rather than from machines. These are the signals I scan for:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;What it looks like&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;th&gt;How to check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Long Base64&lt;/td&gt;
&lt;td&gt;200+ chars of &lt;code&gt;[A-Za-z0-9+/=]&lt;/code&gt; in a string&lt;/td&gt;
&lt;td&gt;Hides URLs, commands or a second-stage script&lt;/td&gt;
&lt;td&gt;Decode and read; strings with no business in a utility are red flags&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hex / &lt;code&gt;\x&lt;/code&gt; escapes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"\x68\x74\x74\x70"&lt;/code&gt; chains&lt;/td&gt;
&lt;td&gt;Hides URLs and keywords from grep&lt;/td&gt;
&lt;td&gt;Decode, then grep again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic eval&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;eval&lt;/code&gt;, &lt;code&gt;new Function&lt;/code&gt;, &lt;code&gt;vm.runInNewContext&lt;/code&gt; on decoded data&lt;/td&gt;
&lt;td&gt;Executes the hidden payload&lt;/td&gt;
&lt;td&gt;Trace the argument back to its source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Child processes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;child_process.exec&lt;/code&gt;, &lt;code&gt;spawn&lt;/code&gt; with curl, sh, powershell&lt;/td&gt;
&lt;td&gt;Runs system commands at install&lt;/td&gt;
&lt;td&gt;Check what command and why&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Env harvesting&lt;/td&gt;
&lt;td&gt;Loops over &lt;code&gt;process.env&lt;/code&gt;, reads &lt;code&gt;.npmrc&lt;/code&gt;, SSH or cloud credential paths&lt;/td&gt;
&lt;td&gt;Credential theft&lt;/td&gt;
&lt;td&gt;Almost never legitimate in install scripts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invisible Unicode&lt;/td&gt;
&lt;td&gt;Zero-width chars, bidi controls like U+202E&lt;/td&gt;
&lt;td&gt;Makes code read differently than it runs&lt;/td&gt;
&lt;td&gt;Inspect code points, not glyphs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Homoglyphs&lt;/td&gt;
&lt;td&gt;Cyrillic &lt;code&gt;а&lt;/code&gt; (U+0430) in an identifier or hostname&lt;/td&gt;
&lt;td&gt;Looks like Latin &lt;code&gt;a&lt;/code&gt;, resolves somewhere else&lt;/td&gt;
&lt;td&gt;Mixed-script detection per token&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A quick scanner for the first and last rows:&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;# long Base64-looking strings&lt;/span&gt;
grep -rEno &lt;span class="s1"&gt;'[A-Za-z0-9+/]{200,}={0,2}'&lt;/span&gt; node_modules/some-util &lt;span class="o"&gt;|&lt;/span&gt; cut -c1-120

&lt;span class="c"&gt;# non-ASCII characters in JS files, with code points&lt;/span&gt;
grep -rnP &lt;span class="s1"&gt;'[^\x00-\x7F]'&lt;/span&gt; --include=&lt;span class="s1"&gt;'*.js'&lt;/span&gt; node_modules/some-util \
  &lt;span class="o"&gt;|&lt;/span&gt; head -20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When something turns up, decode it outside the process. I paste suspicious blobs into the PixelValut &lt;a href="https://pixelvalut.com/tools/base64?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=extension-npm-postinstall-forensics" rel="noopener noreferrer"&gt;Base64 decoder&lt;/a&gt;, which runs in the browser and never executes the result, and decode repeatedly until it stops changing, because layered encoding is common. For lines that look normal but behave strangely, the &lt;a href="https://pixelvalut.com/tools/unicode-inspector?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=extension-npm-postinstall-forensics" rel="noopener noreferrer"&gt;Unicode inspector&lt;/a&gt; shows every code point and script, so a Cyrillic letter inside a Latin hostname or a hidden bidi override becomes obvious. This is the same class of trick as the Trojan Source research on bidirectional control characters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: watch the network, not just the code
&lt;/h2&gt;

&lt;p&gt;Static review misses payloads fetched at runtime. Run the install where unexpected traffic either fails loudly or is recorded:&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;# 1) No network at all: anything that needs it will fail visibly&lt;/span&gt;
docker run --rm --network none -v &lt;span class="s2"&gt;"$PWD"&lt;/span&gt;:/app -v &lt;span class="s2"&gt;"$HOME/.npm"&lt;/span&gt;:/root/.npm -w /app node:22 \
  sh -c &lt;span class="s2"&gt;"npm ci --offline --ignore-scripts=false"&lt;/span&gt;

&lt;span class="c"&gt;# 2) Record outbound connections during a normal install (Linux)&lt;/span&gt;
strace -f -e &lt;span class="nv"&gt;trace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;connect -o install.trace npm ci
grep -E &lt;span class="s1"&gt;'sin_port=htons\((80|443)\)'&lt;/span&gt; install.trace &lt;span class="o"&gt;|&lt;/span&gt; sort -u
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the first option, run one normal &lt;code&gt;npm ci --ignore-scripts&lt;/code&gt; on the host first so the mounted cache has every tarball and &lt;code&gt;--offline&lt;/code&gt; can install from it. In CI, an egress allowlist that permits only your registry or proxy is the cheapest long-term control, and it is the one that caught our incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browser extensions: the same review, different files
&lt;/h2&gt;

&lt;p&gt;Extensions are ZIP archives with a manifest. On Chrome, installed copies live under the profile's &lt;code&gt;Extensions/&amp;lt;id&amp;gt;/&amp;lt;version&amp;gt;/&lt;/code&gt; folder, so you can inspect exactly what is running. Start with the manifest:&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="nv"&gt;EXT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.config/google-chrome/Default/Extensions/&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;id&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;version&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
jq &lt;span class="s1"&gt;'{name, version, permissions, host_permissions,
     cs: [.content_scripts[]? | {matches, js, all_frames}],
     externally_connectable}'&lt;/span&gt; &lt;span class="s2"&gt;"$EXT/manifest.json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What to flag:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt; or &lt;code&gt;*://*/*&lt;/code&gt;&lt;/strong&gt; in &lt;code&gt;host_permissions&lt;/code&gt; or content script &lt;code&gt;matches&lt;/code&gt; for an extension that only needs one site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;scripting&lt;/code&gt;, &lt;code&gt;webRequest&lt;/code&gt;, &lt;code&gt;cookies&lt;/code&gt;, &lt;code&gt;debugger&lt;/code&gt;, &lt;code&gt;nativeMessaging&lt;/code&gt;&lt;/strong&gt; without a clear reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permission growth between versions.&lt;/strong&gt; Diff manifests across updates the same way you diff npm tarballs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote config that changes behaviour.&lt;/strong&gt; Manifest V3 forbids remotely hosted code, but an extension can still fetch configuration that tells its existing code which pages to target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Homoglyph names in the store listing&lt;/strong&gt; that imitate a popular extension.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then load it in a throwaway profile, open the extension's service worker in DevTools, and watch the Network panel while you browse a test page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnostic checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;code&gt;ignore-scripts&lt;/code&gt; is the default in CI and on developer machines, with an explicit allowlist.&lt;/li&gt;
&lt;li&gt;[ ] Packages declaring install scripts are listed and diffed on every lockfile change.&lt;/li&gt;
&lt;li&gt;[ ] New install scripts in patch releases trigger human review.&lt;/li&gt;
&lt;li&gt;[ ] Version-to-version tarball diffs are done for flagged packages.&lt;/li&gt;
&lt;li&gt;[ ] Code is scanned for long Base64, hex escapes, eval and child processes.&lt;/li&gt;
&lt;li&gt;[ ] Non-ASCII, zero-width and bidi characters are checked by code point.&lt;/li&gt;
&lt;li&gt;[ ] Decoding happens in a tool that never executes the output.&lt;/li&gt;
&lt;li&gt;[ ] Installs run with network denied or recorded; CI has an egress allowlist.&lt;/li&gt;
&lt;li&gt;[ ] Extension manifests are reviewed for broad hosts and risky permissions.&lt;/li&gt;
&lt;li&gt;[ ] Extension permission changes across versions are diffed.&lt;/li&gt;
&lt;li&gt;[ ] Any unexpected outbound connection is treated as an incident: rotate exposed tokens.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Is disabling install scripts going to break my build?
&lt;/h3&gt;

&lt;p&gt;Some packages with native binaries need their scripts. Allowlist those explicitly and rebuild them; most pure JavaScript packages do not need scripts at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does &lt;code&gt;npm audit&lt;/code&gt; catch malicious postinstall scripts?
&lt;/h3&gt;

&lt;p&gt;It reports known advisories. A freshly published malicious version may not have an advisory yet, which is why behaviour-based checks matter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can npm package names contain homoglyphs?
&lt;/h3&gt;

&lt;p&gt;New package names must be URL-safe lowercase, so attackers usually rely on typosquatting instead. Homoglyphs show up more in code, hostnames, extension listings and identifiers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a lockfile enough protection?
&lt;/h3&gt;

&lt;p&gt;A lockfile pins versions and integrity hashes, which stops silent swaps. It does not judge whether the pinned version was malicious when you locked it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What should I do if a script already ran?
&lt;/h3&gt;

&lt;p&gt;Assume anything in the environment was read. Rotate npm, cloud, Git, and CI tokens, check for new SSH keys or startup entries, and review recent publishes from your accounts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Are Manifest V3 extensions safe by design?
&lt;/h3&gt;

&lt;p&gt;They are more constrained, mainly because remotely hosted code is not allowed. Broad host permissions still let a content script read and change pages.&lt;/p&gt;

&lt;p&gt;Next in this series, Part 6 covers browser secrets that leak: tokens, storage quotas and exfiltration paths.&lt;/p&gt;




&lt;h3&gt;
  
  
  Follow &amp;amp; Connect
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Website:&lt;/strong&gt; &lt;a href="https://pixelvalut.com/tools/?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=extension-npm-postinstall-forensics" rel="noopener noreferrer"&gt;PixelValut Tools&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;YouTube:&lt;/strong&gt; &lt;a href="https://www.youtube.com/@pixel-valut" rel="noopener noreferrer"&gt;PixelValut (@pixel-valut)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TikTok:&lt;/strong&gt; &lt;a href="https://www.tiktok.com/@pixelvalut007" rel="noopener noreferrer"&gt;PixelValut (@pixelvalut007)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ali111887" rel="noopener noreferrer"&gt;Muhammad Ali Mughal (@ali111887)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/muhammad-ali-m-03434a120/" rel="noopener noreferrer"&gt;Muhammad Ali Mughal&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>npm</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Async Video &amp; Written Interview Answers That Sound Like You</title>
      <dc:creator>Muhammad Ali Mughal</dc:creator>
      <pubDate>Tue, 10 Nov 2026 16:08:45 +0000</pubDate>
      <link>https://dev.to/muhammadalimughal/async-video-written-interview-answers-that-sound-like-you-5acc</link>
      <guid>https://dev.to/muhammadalimughal/async-video-written-interview-answers-that-sound-like-you-5acc</guid>
      <description>&lt;p&gt;Last year I reviewed a batch of written screening answers for a senior backend role. The question was simple: "Tell us about a production incident you owned." Out of the first dozen answers, several opened with nearly the same sentence about "a fast-paced environment" and "cross-functional collaboration." None of them named a system, a number or a mistake. Then one candidate wrote: "At 2:10 AM our Laravel queue workers stopped consuming because Redis hit maxmemory and started evicting keys. I was on call. Here is what I got wrong first." I moved that answer to the top of the pile before I finished the paragraph.&lt;/p&gt;

&lt;p&gt;I wrote this with help from AI tools and checked every template, script and example myself. I am a Principal Software Engineer in Karachi with 9+ years across PHP/Laravel, Vue, Node and .NET microservices. I sit on hiring loops, I review async screening answers, and I build Smart Resume Analyzer. This is Part 5 of the series Interview Proof Beyond the Resume. &lt;a href="https://dev.to/muhammadalimughal/take-home-tests-that-prove-depth-without-unpaid-labor-theater-2ak"&gt;Part 1 covered take-home tests&lt;/a&gt;, &lt;a href="https://dev.to/muhammadalimughal/live-coding-without-freezing-narration-checklists-partial-credit-j5o"&gt;Part 2 covered live coding&lt;/a&gt;, &lt;a href="https://dev.to/muhammadalimughal/system-design-interviews-from-production-scars-not-blog-diagrams-35gb"&gt;Part 3 covered system design from production scars&lt;/a&gt; and &lt;a href="https://dev.to/muhammadalimughal/case-study-portfolios-that-humans-and-ats-both-parse-352e"&gt;Part 4 covered case-study portfolios&lt;/a&gt;. Async rounds are where all of that proof either survives or turns into generic filler.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;📚 Series Roadmap — Interview Proof Beyond the Resume&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/take-home-tests-that-prove-depth-without-unpaid-labor-theater-2ak"&gt;Take-Home Tests That Prove Depth Without Unpaid Labor Theater&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 2:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/live-coding-without-freezing-narration-checklists-partial-credit-j5o"&gt;Live Coding Without Freezing: Narration, Checklists &amp;amp; Partial Credit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/system-design-interviews-from-production-scars-not-blog-diagrams-35gb"&gt;System Design Interviews From Production Scars, Not Blog Diagrams&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 4:&lt;/strong&gt; &lt;a href="https://dev.to/muhammadalimughal/case-study-portfolios-that-humans-and-ats-both-parse-352e"&gt;Case-Study Portfolios That Humans and ATS Both Parse&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 5 (This Post):&lt;/strong&gt; Async Video &amp;amp; Written Interview Answers That Sound Like You&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 6:&lt;/strong&gt; Negotiating Level From Evidence: Mapping Proof to Staff vs Senior&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Related earlier posts:&lt;/em&gt; &lt;a href="https://dev.to/muhammadalimughal/ai-drafted-resumes-and-recruiter-skepticism-making-llm-bullets-sound-like-you-4me3"&gt;AI-Drafted Resumes and Recruiter Skepticism&lt;/a&gt; · &lt;a href="https://dev.to/muhammadalimughal/surviving-ats-knock-out-questions-work-authorization-filters-years-of-experience-traps-3c84"&gt;Surviving ATS Knock-Out Questions&lt;/a&gt; · &lt;a href="https://dev.to/muhammadalimughal/tailoring-without-lying-mapping-job-description-keywords-to-real-evidence-2il6"&gt;Tailoring Without Lying&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How do you answer async video and written interview questions without sounding scripted?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Direct answer:&lt;/strong&gt; Build each answer from one real story with a named system, your specific decision, a number and one honest mistake. Draft in bullet notes, not full sentences, then speak or write from the notes. Use AI only to check structure and length, never to generate the story. Keep your own vocabulary, and match the answer to the role's actual requirements.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fthyprx6lvswrsngtd6dq.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fthyprx6lvswrsngtd6dq.jpg" alt="Closed laptop on a light oak desk beside a potted succulent in soft window light, AI-generated" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why async rounds feel different on the other side
&lt;/h2&gt;

&lt;p&gt;When you answer live, the interviewer hears you think. When you answer async, the reviewer sees only the finished artefact, often next to many others for the same prompt, sometimes at speed. That changes what stands out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sameness is visible.&lt;/strong&gt; In a live loop, a generic sentence is forgiven because the follow-up question rescues it. In a stack of written answers, the same opening line repeated across candidates is obvious.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is no follow-up.&lt;/strong&gt; Whatever you leave out, the reviewer cannot ask about. Context has to be inside the answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reviewers skim first.&lt;/strong&gt; In my experience, the first two sentences decide whether the rest gets read carefully.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The good news: the evidence you built for your resume, take-homes and case studies is exactly what makes async answers strong. You are packaging it for a reader who cannot interrupt you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The anatomy of an answer that sounds like a person
&lt;/h2&gt;

&lt;p&gt;I use a five-part structure for both written and video answers. It is close to STAR, but it front-loads the specifics and adds the part most people skip: what you got wrong.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;What goes in it&lt;/th&gt;
&lt;th&gt;Written length&lt;/th&gt;
&lt;th&gt;Video length&lt;/th&gt;
&lt;th&gt;Sounds scripted when...&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hook&lt;/td&gt;
&lt;td&gt;One concrete sentence: system, time, stakes&lt;/td&gt;
&lt;td&gt;1 sentence&lt;/td&gt;
&lt;td&gt;~10 s&lt;/td&gt;
&lt;td&gt;It starts with "In today's fast-paced world"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context&lt;/td&gt;
&lt;td&gt;Team size, your role, the constraint&lt;/td&gt;
&lt;td&gt;1–2 sentences&lt;/td&gt;
&lt;td&gt;~15 s&lt;/td&gt;
&lt;td&gt;It lists every technology you know&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decision&lt;/td&gt;
&lt;td&gt;What you chose and what you rejected&lt;/td&gt;
&lt;td&gt;2–3 sentences&lt;/td&gt;
&lt;td&gt;~30 s&lt;/td&gt;
&lt;td&gt;It says "we" for every action&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Result&lt;/td&gt;
&lt;td&gt;A number or observable outcome&lt;/td&gt;
&lt;td&gt;1 sentence&lt;/td&gt;
&lt;td&gt;~10 s&lt;/td&gt;
&lt;td&gt;The number is round and unexplained&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lesson&lt;/td&gt;
&lt;td&gt;One mistake and what you changed&lt;/td&gt;
&lt;td&gt;1–2 sentences&lt;/td&gt;
&lt;td&gt;~15 s&lt;/td&gt;
&lt;td&gt;It ends with "I learned teamwork is key"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A written answer built this way usually lands between 150 and 250 words. A spoken one lands around 90 seconds. If the platform gives you a time limit or word cap, the structure still works; you just compress context.&lt;/p&gt;

&lt;h3&gt;
  
  
  A worked example
&lt;/h3&gt;

&lt;p&gt;The prompt: "Describe a time you improved the performance of a system."&lt;/p&gt;

&lt;p&gt;Generic version, the kind that blends into the pile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;In my previous role, I was responsible for optimizing system performance.
I collaborated with cross-functional teams to identify bottlenecks and
implemented best practices, resulting in significantly improved performance.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same candidate, rebuilt from notes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Our invoice export endpoint in a Laravel monolith timed out for any tenant
with more than about 40k invoices. I was the backend lead on a team of four.
Profiling showed an N+1 query on line items and a full collection loaded
into memory. I replaced it with a chunked query plus eager loading and moved
the export to a queued job that emails a signed download link. p95 went from
timeouts at 30 s to under 4 s for the request, and the export job itself
finishes in a few minutes for our largest tenant. My first fix was just
raising the PHP timeout, which hid the problem for a week. Now I profile
before I touch config.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every sentence in the second version could only have been written by someone who did the work. That is the whole trick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Draft from notes, not from a script
&lt;/h2&gt;

&lt;p&gt;Full scripts are the main reason video answers sound recited. People write paragraphs, memorise them, then read them off the screen with their eyes visibly tracking left to right. Instead, keep a notes file per story with five bullets and the numbers:&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="gu"&gt;## Story: invoice export timeouts
&lt;/span&gt;&lt;span class="k"&gt;- &lt;/span&gt;hook: export timed out &amp;gt;40k invoices, Laravel monolith
&lt;span class="k"&gt;- &lt;/span&gt;me: backend lead, team of 4
&lt;span class="k"&gt;- &lt;/span&gt;decision: chunk + eager load, queued job + signed link (rejected: bigger timeout, read replica)
&lt;span class="k"&gt;- &lt;/span&gt;result: request p95 30s timeout -&amp;gt; &amp;lt;4s
&lt;span class="k"&gt;- &lt;/span&gt;mistake: raised timeout first, hid it for a week
&lt;span class="k"&gt;- &lt;/span&gt;maps to: performance, Laravel, queues, ownership
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For written answers, write directly from the bullets in one pass, then edit. For video, glance at the bullets, record, and accept small pauses. A natural pause sounds more credible than a perfectly smooth paragraph.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking your own transcript
&lt;/h2&gt;

&lt;p&gt;Most async video tools let you re-record, and many phones and meeting apps can produce a transcript. I paste the transcript into a file and run a small script to catch the habits I cannot hear myself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="no"&gt;FILLERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"basically"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"actually"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"you know"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"kind of"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"sort of"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"like i said"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="no"&gt;CLICHES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"fast-paced"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"cross-functional"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"best practices"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"synergy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="s2"&gt;"team player"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"passionate about"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"leverage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"results-driven"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"utf-8"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&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;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&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="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;
&lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;"[a-z0-9']+"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"words: {len(words)}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"pace: {len(words) / (seconds / 60):.0f} words/min"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;terms&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"filler"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;FILLERS&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="s2"&gt;"cliche"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;CLICHES&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;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;terms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;"\b"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;"\b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;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;if&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"{label}: '{t}' x{n}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;nums&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;"\b\d[\d.,%]*\b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"numbers mentioned: {len(nums)} {nums[:8]}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;we&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;" we "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;" i "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"'we' vs 'i': {we} / {i}"&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 as &lt;code&gt;python check_answer.py transcript.txt 92&lt;/code&gt;. What I look for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pace.&lt;/strong&gt; Conversational English is often quoted at roughly 120–160 words per minute. If you are far above that, you are rushing a memorised script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero numbers.&lt;/strong&gt; An answer about impact with no numbers is a claim, not evidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;All "we", no "I".&lt;/strong&gt; Credit the team, but the reviewer is hiring you. Say which decision was yours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clichés.&lt;/strong&gt; One is fine. Three in 90 seconds sounds like a template.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where AI helps and where it hurts
&lt;/h2&gt;

&lt;p&gt;I use AI tools daily, and I am not going to tell you to avoid them. But async answers are where AI drafting is easiest to spot, because many candidates prompt similar tools with the same question. Use it as an editor, not an author:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Good use of AI&lt;/th&gt;
&lt;th&gt;Bad use of AI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Structure&lt;/td&gt;
&lt;td&gt;"Does this answer have a hook, decision, result and lesson?"&lt;/td&gt;
&lt;td&gt;"Write me an answer about a performance improvement"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Length&lt;/td&gt;
&lt;td&gt;"Cut this to 180 words without removing numbers"&lt;/td&gt;
&lt;td&gt;"Make this sound more professional"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clarity&lt;/td&gt;
&lt;td&gt;"Which sentence is ambiguous to a non-Laravel reader?"&lt;/td&gt;
&lt;td&gt;"Rewrite in a confident tone"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Role fit&lt;/td&gt;
&lt;td&gt;"Which job requirements does this story cover?"&lt;/td&gt;
&lt;td&gt;"Add keywords from the job description"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"More professional" and "confident tone" are the prompts that strip your voice and add the same phrases everyone else gets. If a tool suggests a sentence you would never say out loud, delete it. Also read the employer's instructions: some explicitly ask candidates not to use AI-generated answers, and you should respect that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mapping answers to the role, honestly
&lt;/h2&gt;

&lt;p&gt;Async prompts are usually derived from the job's core requirements. Before recording, I list the requirements and check which of my stories cover them. Running the resume and the job description through an &lt;a href="https://smartresumeanalyzer.com/ats-resume-checker?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=async-video-written-interview-answers" rel="noopener noreferrer"&gt;ATS resume checker&lt;/a&gt; shows which skills the posting emphasises and which of them your resume already evidences. If a skill appears in the posting but nowhere in your proof, that is a gap to address honestly in the answer, not a keyword to sprinkle in.&lt;/p&gt;

&lt;p&gt;It also helps to check that your resume and your answers tell the same story. A reviewer who watches a strong video and then opens a weak resume notices the mismatch. I run a quick pass on the &lt;a href="https://smartresumeanalyzer.com/resume-score-calculator?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=async-video-written-interview-answers" rel="noopener noreferrer"&gt;resume score calculator&lt;/a&gt; to see whether the stories I am telling in answers actually show up as quantified bullets on the resume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Video basics that remove distractions
&lt;/h2&gt;

&lt;p&gt;You do not need a studio. Put the camera at eye level, keep the light in front of you rather than behind, and use wired earphones or a cheap external mic instead of a laptop mic in an echoey room. Look at the lens for your key point. Do one practice take, then record for real, and check the platform's retake rules before you start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnostic checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Each answer is built from one real story with a named system or project.&lt;/li&gt;
&lt;li&gt;[ ] The first sentence contains a concrete detail, not a generic opener.&lt;/li&gt;
&lt;li&gt;[ ] Your own decision is stated with "I", and alternatives you rejected are named.&lt;/li&gt;
&lt;li&gt;[ ] At least one number or observable result is included and explainable.&lt;/li&gt;
&lt;li&gt;[ ] One honest mistake or lesson is included.&lt;/li&gt;
&lt;li&gt;[ ] Drafted from bullet notes, not a memorised paragraph.&lt;/li&gt;
&lt;li&gt;[ ] Transcript checked for pace, fillers, clichés and "we/I" balance.&lt;/li&gt;
&lt;li&gt;[ ] AI used only for structure, length and clarity, if allowed by the employer.&lt;/li&gt;
&lt;li&gt;[ ] Each answer maps to a real requirement in the job description.&lt;/li&gt;
&lt;li&gt;[ ] Resume bullets back up every story told in the answers.&lt;/li&gt;
&lt;li&gt;[ ] Video framing, lighting and audio checked with one test recording.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  How long should a written async answer be?
&lt;/h3&gt;

&lt;p&gt;Unless a limit is given, 150–250 words is a good range for one story. Long enough for context and a result, short enough to be read fully.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it okay to read from notes during a video answer?
&lt;/h3&gt;

&lt;p&gt;Glancing at bullet notes is fine and usually invisible. Reading a full script is what reviewers notice, because your eyes move steadily across the screen.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can reviewers tell if I used AI to write my answer?
&lt;/h3&gt;

&lt;p&gt;Not reliably by tooling, but generic phrasing and missing specifics are easy for a human to notice. Answers with real system names, numbers and mistakes read as yours regardless.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if I do not have a dramatic incident story?
&lt;/h3&gt;

&lt;p&gt;You do not need one. A small, well-measured improvement with a clear decision and lesson is stronger than a vague story about a big outage.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if my English is not my first language?
&lt;/h3&gt;

&lt;p&gt;Clear and specific beats polished. Short sentences with concrete details are easier to follow than elaborate phrasing, and reviewers are listening for evidence, not accent.&lt;/p&gt;

&lt;p&gt;Next in this series, Part 6 covers negotiating level from evidence and mapping your proof to Staff versus Senior expectations.&lt;/p&gt;




&lt;h3&gt;
  
  
  Follow &amp;amp; Connect
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Website:&lt;/strong&gt; &lt;a href="https://smartresumeanalyzer.com/?utm_source=devto&amp;amp;utm_medium=series&amp;amp;utm_campaign=async-video-written-interview-answers" rel="noopener noreferrer"&gt;Smart Resume Analyzer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;YouTube:&lt;/strong&gt; &lt;a href="https://www.youtube.com/@resume-boost-ai" rel="noopener noreferrer"&gt;Resume Boost AI (@resume-boost-ai)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TikTok:&lt;/strong&gt; &lt;a href="https://www.tiktok.com/@resumeboostai" rel="noopener noreferrer"&gt;Resume Boost AI (@resumeboostai)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X:&lt;/strong&gt; &lt;a href="https://x.com/AliMughal887" rel="noopener noreferrer"&gt;Muhammad Ali Mughal (@AliMughal887)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ali111887" rel="noopener noreferrer"&gt;Muhammad Ali Mughal (@ali111887)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/muhammad-ali-m-03434a120/" rel="noopener noreferrer"&gt;Muhammad Ali Mughal&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>career</category>
      <category>interview</category>
      <category>productivity</category>
      <category>ai</category>
    </item>
    <item>
      <title>How ATS Parses Phone Numbers &amp; Country Codes</title>
      <dc:creator>Muhammad Ali Mughal</dc:creator>
      <pubDate>Tue, 10 Nov 2026 15:44:39 +0000</pubDate>
      <link>https://dev.to/muhammadalimughal/how-ats-parses-phone-numbers-country-codes-3hha</link>
      <guid>https://dev.to/muhammadalimughal/how-ats-parses-phone-numbers-country-codes-3hha</guid>
      <description>&lt;p&gt;A recruiter dials the number on your profile and hears "the number you have dialed is not in service." Or worse: the ATS never stored a phone at all, so the recruiter never calls. Your header looked fine to you—"📞 +1 (415) 555-0198 · WhatsApp preferred · ext 442"—but Greenhouse, Workday, Lever, iCIMS or Taleo often tokenize that line into digits, country code, extension and free-text leftovers. Icons, parentheses, WhatsApp labels and dual numbers decide whether a structured phone field fills cleanly or stays empty.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://smartresumeanalyzer.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=ats-phone-numbers-country-codes" rel="noopener noreferrer"&gt;Smart Resume Analyzer&lt;/a&gt;, an ATS resume checker, and I have spent nine years as a Principal Software Engineer in Karachi writing parsers and matching code in PHP, Laravel, Vue, Nuxt, Node and TypeScript. I wrote this with help from AI tools and checked every command and claim myself. This piece stays on phone numbers: country and dial codes, extensions, WhatsApp-only lines, and how common ATS fields store what you typed.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does ATS parse phone numbers and country codes?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Direct answer:&lt;/strong&gt; Most ATS parsers scan the contact header for digit runs, optional leading &lt;code&gt;+&lt;/code&gt; country codes, and extension markers such as &lt;code&gt;ext&lt;/code&gt;, &lt;code&gt;x&lt;/code&gt; or &lt;code&gt;#&lt;/code&gt;, then write a primary phone into a structured field while leaving labels like WhatsApp in raw text or notes. Put one international-format mobile first (&lt;code&gt;+country&lt;/code&gt; then subscriber digits), keep a second line only if you truly need an alternate, and drop icons, multi-app labels and decorative separators that break field extraction.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the phone line actually becomes
&lt;/h2&gt;

&lt;p&gt;After upload, your resume is not one blob. The parser tries to fill a candidate profile. Phone data usually lands in a few places:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Destination&lt;/th&gt;
&lt;th&gt;Typical source on the resume&lt;/th&gt;
&lt;th&gt;What breaks it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Primary phone field&lt;/td&gt;
&lt;td&gt;First digit-heavy token near the top&lt;/td&gt;
&lt;td&gt;Two numbers on one line; emoji before digits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Country / dial code&lt;/td&gt;
&lt;td&gt;Leading &lt;code&gt;+&lt;/code&gt; and country code, or inferred from locale&lt;/td&gt;
&lt;td&gt;Local format only when applying abroad&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extension&lt;/td&gt;
&lt;td&gt;Tokens after &lt;code&gt;ext&lt;/code&gt;, &lt;code&gt;x&lt;/code&gt;, &lt;code&gt;ext.&lt;/code&gt;, or &lt;code&gt;#&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Writing "extension four four two" in words&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alternate / mobile vs work&lt;/td&gt;
&lt;td&gt;Second number or a label ("mobile", "office")&lt;/td&gt;
&lt;td&gt;Stacking WhatsApp, Signal and Telegram as "phones"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recruiter keyword search&lt;/td&gt;
&lt;td&gt;Raw resume text still indexed&lt;/td&gt;
&lt;td&gt;Fancy unicode dashes, icons, multi-column headers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Structured fields power click-to-call and SMS workflows. Raw text still powers keyword search for words like &lt;code&gt;WhatsApp&lt;/code&gt; or &lt;code&gt;available after 5pm PKT&lt;/code&gt;. You need the structured phone to be dialable, and the surrounding words to be honest.&lt;/p&gt;

&lt;p&gt;Phone lines follow the same contact-header discipline as &lt;a href="https://dev.to/muhammadalimughal/how-ats-parses-your-resume-contact-header-1c5g"&gt;how ATS parses your resume contact header&lt;/a&gt;: plain text near the top, one idea per line, no icons.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Greenhouse, Workday, Lever, iCIMS and Taleo treat phones
&lt;/h2&gt;

&lt;p&gt;Vendors differ, and each employer's configuration differs more. Treat this as a safe operating assumption, not a claim about one customer's setup or any vendor statistic.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;ATS&lt;/th&gt;
&lt;th&gt;How phone usually lands&lt;/th&gt;
&lt;th&gt;Country code / extension&lt;/th&gt;
&lt;th&gt;Safe assumption for your resume&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Greenhouse&lt;/td&gt;
&lt;td&gt;Parsed into candidate phone; recruiters click-to-call from the profile&lt;/td&gt;
&lt;td&gt;Country code often kept if you include &lt;code&gt;+&lt;/code&gt;; extensions may land in notes&lt;/td&gt;
&lt;td&gt;One &lt;code&gt;+&lt;/code&gt;-prefixed mobile first; put extension on its own short phrase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lever&lt;/td&gt;
&lt;td&gt;Profile phone plus resume text search&lt;/td&gt;
&lt;td&gt;Labels and tags are common for "mobile" vs "WhatsApp"&lt;/td&gt;
&lt;td&gt;Same; keep WhatsApp as a preference sentence, not the only number&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workday&lt;/td&gt;
&lt;td&gt;Strong structured profile fields; phone types (home/mobile/work)&lt;/td&gt;
&lt;td&gt;Application forms often collect phone separately from the resume&lt;/td&gt;
&lt;td&gt;Fill the form carefully; mirror the same digits on the resume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iCIMS&lt;/td&gt;
&lt;td&gt;Profile fields and resume keyword search&lt;/td&gt;
&lt;td&gt;Preferences and SMS consent frequently live on the application&lt;/td&gt;
&lt;td&gt;Do not rely on the resume alone for SMS opt-in or alternate channels&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Taleo&lt;/td&gt;
&lt;td&gt;Parsed fields vary heavily by Oracle/Taleo config&lt;/td&gt;
&lt;td&gt;Screening questions may ask for a daytime phone&lt;/td&gt;
&lt;td&gt;Answer questions and keep the resume consistent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These products extract tokens and fill fields; they do not "understand" clever contact art. Graphic banners and two-column headers with the phone on the right drop extraction quality—the same failure mode as &lt;a href="https://dev.to/muhammadalimughal/hidden-characters-that-break-ats-keyword-matching-56ce"&gt;hidden characters that break ATS keyword matching&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tokenization: what "+92 300 1234567" becomes
&lt;/h2&gt;

&lt;p&gt;A simplified tokenizer keeps digits and a leading plus, then splits everything else:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;phone_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# keep +digit runs; drop other punctuation into separators&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;"\+?\d+"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;"[^\d+]+"&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="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;samples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;"+1 (415) 555-0198"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"📞 +92-300-1234567 · WhatsApp preferred"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"415.555.0198 ext 442"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"WhatsApp only: +44 7700 900123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"+49 30 123456 / office +49 30 654321"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"-&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;phone_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&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;Typical output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+1 (415) 555-0198 -&amp;gt; ['+1', '415', '555', '0198']
📞 +92-300-1234567 · WhatsApp preferred -&amp;gt; ['+92', '300', '1234567']
415.555.0198 ext 442 -&amp;gt; ['415', '555', '0198', '442']
WhatsApp only: +44 7700 900123 -&amp;gt; ['+44', '7700', '900123']
+49 30 123456 / office +49 30 654321 -&amp;gt; ['+49', '30', '123456', '49', '30', '654321']
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parentheses, dots and hyphens usually vanish as separators. Emoji become separators or vanish. Two numbers on one line often collide into one messy field or leave the second number only in raw text. "WhatsApp only" with no dialable PSTN number may leave the primary phone empty in systems that expect a classic phone field.&lt;/p&gt;

&lt;h2&gt;
  
  
  Weak vs ATS-optimized phone lines
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Weak&lt;/th&gt;
&lt;th&gt;ATS-optimized&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;`📞 (415) 555-0198 \&lt;/td&gt;
&lt;td&gt;WA preferred`&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;+1 415 555 0198&lt;/code&gt; plus &lt;code&gt;WhatsApp OK for scheduling&lt;/code&gt; on the next short phrase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;3001234567&lt;/code&gt; (local only, applying to US/EU roles)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;+92 300 1234567&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;International &lt;code&gt;+&lt;/code&gt; country code survives cross-border parsing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WhatsApp only — message before calling&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;+44 7700 900123&lt;/code&gt; plus &lt;code&gt;Prefer WhatsApp message before voice call&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Keep a dialable number in the phone field&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Office: 555-0198 x442 / Cell: same&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;+1 415 555 0198&lt;/code&gt; and, if needed, &lt;code&gt;Office extension 442 during business hours&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;One primary; extension labeled clearly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two mobiles + one landline on one line&lt;/td&gt;
&lt;td&gt;One primary mobile; optional second line for landline&lt;/td&gt;
&lt;td&gt;Multi-number lines confuse which value is primary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone only inside a text box or sidebar&lt;/td&gt;
&lt;td&gt;Phone in the top contact block under your name&lt;/td&gt;
&lt;td&gt;Parsers weight the header; sidebars get skimmed or missed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;+1-800-…&lt;/code&gt; vanity or shared family line&lt;/td&gt;
&lt;td&gt;A number you actually answer during the hiring window&lt;/td&gt;
&lt;td&gt;Undialable or shared numbers waste recruiter time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few extra characters; not stuffing—just field-friendly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl4spg39ge9myfr91kn1e.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl4spg39ge9myfr91kn1e.jpg" alt="Smartphone dial pad resting on a printed resume beside earbuds and a brass paperclip on a wooden desk" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;AI-generated image.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Country codes, extensions and WhatsApp-only lines
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Country / dial codes.&lt;/strong&gt; If you apply outside your home country, lead with E.164-style formatting: &lt;code&gt;+&lt;/code&gt; then country code then subscriber number, spaces optional. Local formats without a country code work inside one market and fail when the employer sits elsewhere. Do not write the country name instead of the code (&lt;code&gt;Pakistan 300…&lt;/code&gt;); parsers look for digits, not demonyms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Extensions.&lt;/strong&gt; Write &lt;code&gt;ext 442&lt;/code&gt; or &lt;code&gt;extension 442&lt;/code&gt; after the main number, or on the next short phrase. Avoid &lt;code&gt;#442&lt;/code&gt; alone if the ATS treats &lt;code&gt;#&lt;/code&gt; as a fragment marker. Spell digits as digits, not words.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WhatsApp, Signal, Telegram.&lt;/strong&gt; These are messaging preferences, not substitutes for the phone field unless the employer explicitly asks for them. Put the dialable mobile first. Add a preference sentence: &lt;code&gt;WhatsApp available for scheduling&lt;/code&gt;. Never replace the only phone with an app handle that cannot be dialed from the ATS click-to-call button.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multiple numbers.&lt;/strong&gt; Prefer one primary mobile you will answer. If you must list a landline, put it on a second plain line with a label. Keep the application form identical—mismatched phones between form and resume create trust problems and duplicate profiles.&lt;/p&gt;

&lt;p&gt;Keep the phone under standard contact headings parsers already recognize—see &lt;a href="https://dev.to/muhammadalimughal/resume-section-headings-what-ats-parsers-recognize-4jgn"&gt;resume section headings ATS parsers recognize&lt;/a&gt;. Do not invent a "Comms" or "Reachability" section just for WhatsApp.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnostic checklist: phone line in 10 minutes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Paste the top six lines into a plain-text editor.&lt;/strong&gt; Fix icons, columns or soft line breaks at the source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm one primary number in &lt;code&gt;+country&lt;/code&gt; format&lt;/strong&gt; you will answer during the hiring window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip emoji and decorative separators&lt;/strong&gt; around the digits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move WhatsApp / Signal preferences&lt;/strong&gt; into a short sentence, not into the phone field itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Label extensions clearly&lt;/strong&gt; with &lt;code&gt;ext&lt;/code&gt; and digits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drop second and third numbers&lt;/strong&gt; from the same line; use a second line only if needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Align resume digits with the application form&lt;/strong&gt; and LinkedIn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search the PDF&lt;/strong&gt; for your country code and the last four digits to confirm nothing vanished in export.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-parse&lt;/strong&gt; with an ATS-style checker such as my &lt;a href="https://smartresumeanalyzer.com/ats-resume-checker?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=ats-phone-numbers-country-codes" rel="noopener noreferrer"&gt;ATS resume checker&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test dialability:&lt;/strong&gt; ask a friend in another country to save the number and confirm it looks correct with the country code.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Pre-submit checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Top of resume shows one primary phone in plain text with &lt;code&gt;+&lt;/code&gt; country code&lt;/li&gt;
&lt;li&gt;[ ] No emoji or icon immediately before the digits&lt;/li&gt;
&lt;li&gt;[ ] WhatsApp / messaging preferences stated in words, not as the only "phone"&lt;/li&gt;
&lt;li&gt;[ ] Extension labeled with &lt;code&gt;ext&lt;/code&gt; and digits if you use one&lt;/li&gt;
&lt;li&gt;[ ] At most one alternate number, on its own line with a clear label&lt;/li&gt;
&lt;li&gt;[ ] Application form phone matches the resume exactly&lt;/li&gt;
&lt;li&gt;[ ] Number is one you answer during business hours in the employer's time zone when possible&lt;/li&gt;
&lt;li&gt;[ ] PDF export still contains the full digit string when searched&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should I put WhatsApp instead of a phone number?
&lt;/h3&gt;

&lt;p&gt;No. Keep a dialable mobile in the phone field, then add that WhatsApp is fine for scheduling. Many ATS click-to-call and SMS tools expect a classic phone value. A WhatsApp-only line can leave the structured field empty.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I always need a plus and country code?
&lt;/h3&gt;

&lt;p&gt;For same-country applications, local formatting often works. For cross-border or remote roles, &lt;code&gt;+&lt;/code&gt; and the country code are the safer default so the number stays unambiguous in Greenhouse, Workday, Lever, iCIMS and Taleo profiles.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where should I put an office extension?
&lt;/h3&gt;

&lt;p&gt;After the main number (&lt;code&gt;+1 415 555 0198 ext 442&lt;/code&gt;) or on the next short phrase. Do not hide the extension inside parentheses with the area code, and do not spell it in words.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I list two mobiles from two countries?
&lt;/h3&gt;

&lt;p&gt;You can, but put the primary first on its own line and the second on the next line with a label such as &lt;code&gt;EU mobile&lt;/code&gt;. One crowded line with two full internationals is a common parse failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will parentheses and dashes hurt parsing?
&lt;/h3&gt;

&lt;p&gt;Usually they are treated as separators and the digits still extract. Problems appear when icons, words and a second number sit on the same line. Plain digits with spaces or hyphens are fine; clarity beats decoration.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if my only reliable channel is WhatsApp because of roaming?
&lt;/h3&gt;

&lt;p&gt;Still list the underlying mobile in &lt;code&gt;+&lt;/code&gt; format, then state the preference: &lt;code&gt;Prefer WhatsApp while roaming; SMS also OK&lt;/code&gt;. Recruiters need a number the ATS can store even when they agree to message first.&lt;/p&gt;




&lt;h3&gt;
  
  
  Follow &amp;amp; Connect
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;YouTube:&lt;/strong&gt; &lt;a href="https://www.youtube.com/@resume-boost-ai" rel="noopener noreferrer"&gt;Resume Boost AI (@resume-boost-ai)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TikTok:&lt;/strong&gt; &lt;a href="https://www.tiktok.com/@resumeboostai" rel="noopener noreferrer"&gt;Resume Boost AI (@resumeboostai)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X:&lt;/strong&gt; &lt;a href="https://x.com/AliMughal887" rel="noopener noreferrer"&gt;Muhammad Ali Mughal (@AliMughal887)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/ali111887" rel="noopener noreferrer"&gt;Muhammad Ali Mughal (@ali111887)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/muhammad-ali-m-03434a120/" rel="noopener noreferrer"&gt;Muhammad Ali Mughal&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>career</category>
      <category>resume</category>
      <category>jobs</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Laravel RBAC for Multi-Organization Portals</title>
      <dc:creator>Muhammad Ali Mughal</dc:creator>
      <pubDate>Tue, 10 Nov 2026 13:41:05 +0000</pubDate>
      <link>https://dev.to/muhammadalimughal/laravel-rbac-for-multi-organization-portals-1dod</link>
      <guid>https://dev.to/muhammadalimughal/laravel-rbac-for-multi-organization-portals-1dod</guid>
      <description>&lt;p&gt;A portal that serves many organizations is not “one app with a role column.” It is a matrix: user × organization × permission, plus the hard rule that a token for Org A must never read Org B’s rows even when the user belongs to both. Most IDOR bugs I hunt in Laravel portals come from checking a global role and forgetting the organization scope on the query.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://looporigin.com/services/laravel-development?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=laravel-rbac-multi-organization-portals" rel="noopener noreferrer"&gt;Loop Origin&lt;/a&gt;, where we ship multi-tenant Laravel systems for dealers, partners, and internal ops portals. I wrote this with help from AI tools and checked every command and claim myself. Below is the RBAC shape I use when one login can administer several organizations without collapsing authorization into a single boolean.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should Laravel RBAC work for multi-organization portals?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Direct answer:&lt;/strong&gt; Model memberships as user-organization links with roles scoped to that organization, authorize with policies that take the org context (not only the user), and force every sensitive query through &lt;code&gt;where organization_id = currentOrg()&lt;/code&gt;. Global roles are for platform staff only; treating “admin” as global across orgs is how IDOR starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data model: membership before role
&lt;/h2&gt;

&lt;p&gt;Avoid &lt;code&gt;users.role = 'admin'&lt;/code&gt;. Prefer an explicit membership:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'organizations'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Blueprint&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&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;primary&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="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timestampsTz&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="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'organization_user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Blueprint&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&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;primary&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="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;foreignUuid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'organization_id'&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;constrained&lt;/span&gt;&lt;span class="p"&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;cascadeOnDelete&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="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;foreignUuid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&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;constrained&lt;/span&gt;&lt;span class="p"&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;cascadeOnDelete&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="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// owner, admin, billing, viewer&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;timestampsTz&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="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'organization_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'user_id'&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="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'organization_id'&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="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'permissions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Blueprint&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&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="nf"&gt;primary&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;// invoices.refund, members.invite&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="nc"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'organization_role_permission'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Blueprint&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'permission'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$table&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'role'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'permission'&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;Permissions are stable strings. Roles are bundles per product. Organization-specific custom roles are possible later; start with a fixed set so policies stay readable.&lt;/p&gt;

&lt;p&gt;Resolve “current organization” once per request (subdomain, path prefix, or explicit header) and store it on the request—never infer it only from the resource id without also verifying membership.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EnsureOrganizationContext&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Closure&lt;/span&gt; &lt;span class="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$orgId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'organization'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;session&lt;/span&gt;&lt;span class="p"&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'current_organization_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="nv"&gt;$membership&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OrganizationUser&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&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;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'organization_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$orgId&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;first&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;abort_unless&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$membership&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'organization_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$membership&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'org_role'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$membership&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;role&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="nv"&gt;$next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&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;h2&gt;
  
  
  Gates, policies, and where org scope belongs
&lt;/h2&gt;

&lt;p&gt;Laravel Gates are fine for coarse checks (&lt;code&gt;$user-&amp;gt;can('members.invite')&lt;/code&gt;) only if &lt;code&gt;can&lt;/code&gt; is evaluated in org context. I bind a permission resolver that reads membership for the current org:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Gate&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;before&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$ability&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="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;isPlatformStaff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$ability&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s1"&gt;'danger.purge'&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="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// platform ops; still scoped in queries below&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&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="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;Gate&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'members.invite'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$orgId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&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="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'organization_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$role&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OrganizationUser&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'organization_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$orgId&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;value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'role'&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="nv"&gt;$role&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nc"&gt;RolePermissions&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;allows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'members.invite'&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;Policies must receive the model &lt;em&gt;and&lt;/em&gt; prove tenancy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InvoicePolicy&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Invoice&lt;/span&gt; &lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$orgId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&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="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'organization_id'&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="nv"&gt;$invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$orgId&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="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// IDOR stop&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Gate&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;forUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&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;allows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'invoices.refund'&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;Never authorize with &lt;code&gt;Gate::allows('invoices.refund')&lt;/code&gt; alone and then &lt;code&gt;Invoice::find($id)&lt;/code&gt;. The find must be scoped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Invoice&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'organization_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'organization_id'&lt;/span&gt;&lt;span class="p"&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;whereKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&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;firstOrFail&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'refund'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$invoice&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;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc4kjupwad468x0ijqqkt.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc4kjupwad468x0ijqqkt.jpg" alt="Multiple labeled keyrings hanging on a wall hooks board, metaphor for per-organization roles and keys that must not open the wrong door" width="800" height="600"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;AI-generated image.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Packages vs custom RBAC
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Fits multi-org well?&lt;/th&gt;
&lt;th&gt;Speed to first portal&lt;/th&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;th&gt;When I choose it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Spatie laravel-permission (teams/org feature)&lt;/td&gt;
&lt;td&gt;Yes if teams enabled carefully&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;Misconfigured global roles&lt;/td&gt;
&lt;td&gt;Standard CRUD portals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom membership + permission tables&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;You own edge cases&lt;/td&gt;
&lt;td&gt;Complex org hierarchies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single global role on users&lt;/td&gt;
&lt;td&gt;Poor&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;Systemic IDOR&lt;/td&gt;
&lt;td&gt;Almost never for portals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OIDC groups from IdP only&lt;/td&gt;
&lt;td&gt;Good for enterprise SSO&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Sync lag, missing local deny&lt;/td&gt;
&lt;td&gt;When customer mandates IdP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Row-level Postgres policies&lt;/td&gt;
&lt;td&gt;Strong defense in depth&lt;/td&gt;
&lt;td&gt;Slower&lt;/td&gt;
&lt;td&gt;Migration/ops complexity&lt;/td&gt;
&lt;td&gt;High-assurance tenants&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Spatie’s teams mode can map to &lt;code&gt;organization_id&lt;/code&gt; if every &lt;code&gt;assignRole&lt;/code&gt; and &lt;code&gt;givePermissionTo&lt;/code&gt; call passes the team id. The failure mode is assigning a role without the team argument once—permission becomes global. Custom tables make that mistake a schema impossibility. For dealer-style portals with tiered commercial rules, I often keep RBAC custom and keep pricing separate; see &lt;a href="https://dev.to/muhammadalimughal/b2b-dealer-portals-tier-pricing-credit-limits-bulk-5230"&gt;B2B dealer portals: tier pricing, credit limits, bulk&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Failure modes that create IDOR
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Resource id without org predicate.&lt;/strong&gt; &lt;code&gt;/invoices/uuid&lt;/code&gt; with a global &lt;code&gt;find&lt;/code&gt; is the classic. Always constrain by &lt;code&gt;organization_id&lt;/code&gt; from context, not from the invoice’s org alone after load without comparison.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User belongs to Org A and Org B; session sticky wrong.&lt;/strong&gt; Switching orgs must rotate &lt;code&gt;current_organization_id&lt;/code&gt; and invalidate cached abilities. Cached &lt;code&gt;Gate&lt;/code&gt; results across orgs are poison.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API tokens without org claim.&lt;/strong&gt; Personal access tokens that authorize any membership the user has must still require an org header or embed &lt;code&gt;organization_id&lt;/code&gt; in the token abilities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Platform staff bypass without query scope.&lt;/strong&gt; &lt;code&gt;Gate::before&lt;/code&gt; returning true must not skip &lt;code&gt;where organization_id&lt;/code&gt;. Staff tools should set an explicit org context or use a separate break-glass query path that is audited.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nested resources.&lt;/strong&gt; &lt;code&gt;/orgs/{org}/projects/{project}/files/{file}&lt;/code&gt; needs every parent checked, not only the leaf policy. Prefer signed route bindings that resolve through the org relationship.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Feature flags per tenant without authz.&lt;/strong&gt; Flags control rollout, not permission. Pair with RBAC; see &lt;a href="https://dev.to/muhammadalimughal/feature-flags-for-multi-tenant-saas-tenant-rollouts-24c9"&gt;feature flags for multi-tenant SaaS&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Uploads and signed URLs.&lt;/strong&gt; File access must re-check org membership on download, not only on upload—covered in spirit by &lt;a href="https://dev.to/muhammadalimughal/secure-saas-file-uploads-signed-urls-scanning-isolation-58g0"&gt;secure SaaS file uploads&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Practical authorization checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Membership table: unique &lt;code&gt;(organization_id, user_id)&lt;/code&gt; with role&lt;/li&gt;
&lt;li&gt;[ ] Permissions are strings; roles map to permission sets&lt;/li&gt;
&lt;li&gt;[ ] Middleware sets &lt;code&gt;organization_id&lt;/code&gt; after membership proof&lt;/li&gt;
&lt;li&gt;[ ] Every sensitive query includes &lt;code&gt;organization_id = context&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Policies compare model org to context before ability checks&lt;/li&gt;
&lt;li&gt;[ ] Org switch clears ability/permission cache&lt;/li&gt;
&lt;li&gt;[ ] API tokens carry or require org scope&lt;/li&gt;
&lt;li&gt;[ ] Platform staff bypass still org-scoped in queries + audited&lt;/li&gt;
&lt;li&gt;[ ] Automated tests: user in Org A cannot read Org B by id&lt;/li&gt;
&lt;li&gt;[ ] Manual IDOR suite on every new resource controller&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example Pest test I require before merge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'blocks cross-organization invoice refund'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&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="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&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;create&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="nv"&gt;$orgA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Organization&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&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;create&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="nv"&gt;$orgB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Organization&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&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;create&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="nc"&gt;OrganizationUser&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&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;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'user_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'organization_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$orgA&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'admin'&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="nv"&gt;$invoiceB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Invoice&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;factory&lt;/span&gt;&lt;span class="p"&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;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'organization_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$orgB&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;actingAs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$user&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;withSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'current_organization_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$orgA&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&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;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'invoices.refund'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$invoiceB&lt;/span&gt;&lt;span class="p"&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;assertForbidden&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;h2&gt;
  
  
  Rollout framework for an existing single-tenant app
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Introduce &lt;code&gt;organizations&lt;/code&gt; and membership; attach existing users to one org.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;organization_id&lt;/code&gt; to tenant-owned tables (expand/contract migration).&lt;/li&gt;
&lt;li&gt;Middleware + session org context before flipping enforcement.&lt;/li&gt;
&lt;li&gt;Convert Gates to org-aware checks; add policy org comparisons.&lt;/li&gt;
&lt;li&gt;Ship IDOR tests for the top ten money and PII routes.&lt;/li&gt;
&lt;li&gt;Only then allow users to join a second organization.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Skip step order and you will “support multi-org” in the UI while the API still trusts global roles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is a global &lt;code&gt;is_admin&lt;/code&gt; flag ever acceptable?
&lt;/h3&gt;

&lt;p&gt;Only for platform operators of the SaaS itself, and even then every data query needs an explicit organization filter or a separate audited admin schema. Customer-org admins must never share that flag.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should permissions live in the JWT?
&lt;/h3&gt;

&lt;p&gt;You can embed a snapshot for performance, but the source of truth remains membership tables. Short TTL and server-side revoke still matter when roles change.&lt;/p&gt;

&lt;h3&gt;
  
  
  How many roles do I need?
&lt;/h3&gt;

&lt;p&gt;Usually four to seven per portal (owner, admin, billing, operator, viewer, plus one vertical-specific role). More roles without clearer permissions become politics, not security.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can one user have different roles in different orgs?
&lt;/h3&gt;

&lt;p&gt;Yes—that is the point of membership rows. Do not collapse to the maximum role across orgs when evaluating an ability.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about organization hierarchies (parent/child)?
&lt;/h3&gt;

&lt;p&gt;Model parent links explicitly and define whether permissions inherit. Inheritance bugs are common; default to no inheritance until product requires it, then test lateral movement between siblings.&lt;/p&gt;

&lt;h3&gt;
  
  
  Package or custom for a greenfield portal?
&lt;/h3&gt;

&lt;p&gt;If the portal is standard CRUD and teams map cleanly to orgs, a maintained package with teams mode is fine. If you have nested orgs, delegated admin, or unusual deny rules, custom membership tables are clearer for &lt;a href="https://looporigin.com/services/custom-web-application-development?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=laravel-rbac-multi-organization-portals" rel="noopener noreferrer"&gt;custom web application development&lt;/a&gt; long term.&lt;/p&gt;

&lt;p&gt;Multi-organization RBAC is mostly discipline: context on the request, scope on every query, policies that refuse cross-org models. Get those three right and the package debate becomes secondary.&lt;/p&gt;




&lt;h3&gt;
  
  
  Follow &amp;amp; Connect
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/company/loop-origin" rel="noopener noreferrer"&gt;Loop Origin&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X:&lt;/strong&gt; &lt;a href="https://x.com/AliMughal887" rel="noopener noreferrer"&gt;Muhammad Ali Mughal (@AliMughal887)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/LoopOrigin" rel="noopener noreferrer"&gt;Loop Origin (@LoopOrigin)&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>security</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Immutable Audit Trails in Postgres for SaaS</title>
      <dc:creator>Muhammad Ali Mughal</dc:creator>
      <pubDate>Tue, 10 Nov 2026 11:31:43 +0000</pubDate>
      <link>https://dev.to/muhammadalimughal/immutable-audit-trails-in-postgres-for-saas-og6</link>
      <guid>https://dev.to/muhammadalimughal/immutable-audit-trails-in-postgres-for-saas-og6</guid>
      <description>&lt;p&gt;Auditors do not care that your admin UI shows a change history. They care whether anyone with database access—or a compromised application role—can rewrite what happened last Tuesday. Mutable &lt;code&gt;updated_at&lt;/code&gt; columns and soft-deleted “history” rows fail that test. An immutable audit trail in Postgres means append-only facts: who did what, to which tenant row, with which before/after payload, at a clock the database itself stamped.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://looporigin.com/services/custom-web-application-development?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=postgres-immutable-audit-trails-saas" rel="noopener noreferrer"&gt;Loop Origin&lt;/a&gt;, where we design Laravel/Postgres SaaS for teams that need SOC2-ready change evidence without bolting on a separate log product on day one. I wrote this with help from AI tools and checked every command and claim myself. The patterns below are what I ship when compliance, support, and incident response all need the same source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you make Postgres audit trails immutable for SaaS?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Direct answer:&lt;/strong&gt; Store audit events in an append-only table with no UPDATE/DELETE grants for the app role, write rows in the same transaction as the business change (or via a security definer trigger), partition by month for retention, and treat corrections as new compensating events—never edits to history. Immutability is a privilege and process design, not a UI feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why UPDATE and DELETE on audit rows break compliance
&lt;/h2&gt;

&lt;p&gt;If the same role that mutates &lt;code&gt;orders&lt;/code&gt; can also &lt;code&gt;UPDATE audit_events SET …&lt;/code&gt; or &lt;code&gt;DELETE FROM audit_events&lt;/code&gt;, the trail is theatre. Common failures I see in reviews:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Soft-delete “history” tables that still allow hard deletes by migrations or ops scripts.&lt;/li&gt;
&lt;li&gt;Triggers that fire only on application UPDATEs, while bulk SQL and admin consoles bypass them.&lt;/li&gt;
&lt;li&gt;Application-layer logging after the commit—process crash, dual-write gap, silent loss.&lt;/li&gt;
&lt;li&gt;Shared superuser credentials for the app pool, so least privilege never existed.&lt;/li&gt;
&lt;li&gt;“Fixing” a bad audit row in place instead of appending a correction event.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The design goal: the application role can INSERT audit rows (or call a function that does) and SELECT them. It cannot UPDATE or DELETE them. Retention jobs run as a separate privileged role with a documented runbook, not as the web user.&lt;/p&gt;

&lt;h2&gt;
  
  
  Append-only schema that survives multi-tenant SaaS
&lt;/h2&gt;

&lt;p&gt;Keep the audit table in the same database as the business data so you can write both in one transaction when the app owns the write 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;TABLE&lt;/span&gt; &lt;span class="n"&gt;audit_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;              &lt;span class="kt"&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="nf"&gt;gen_random_uuid&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="n"&gt;tenant_id&lt;/span&gt;       &lt;span class="kt"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;actor_user_id&lt;/span&gt;   &lt;span class="kt"&gt;uuid&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;actor_type&lt;/span&gt;      &lt;span class="kt"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&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;actor_type&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;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'system'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'support'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'api_key'&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="n"&gt;action&lt;/span&gt;          &lt;span class="kt"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- order.updated, role.granted&lt;/span&gt;
  &lt;span class="n"&gt;entity_type&lt;/span&gt;     &lt;span class="kt"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- Order, Membership&lt;/span&gt;
  &lt;span class="n"&gt;entity_id&lt;/span&gt;       &lt;span class="kt"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;request_id&lt;/span&gt;      &lt;span class="kt"&gt;uuid&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="c1"&gt;-- correlate with APM / logs&lt;/span&gt;
  &lt;span class="n"&gt;ip_inet&lt;/span&gt;         &lt;span class="kt"&gt;inet&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;before_state&lt;/span&gt;    &lt;span class="kt"&gt;jsonb&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;after_state&lt;/span&gt;     &lt;span class="kt"&gt;jsonb&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;metadata&lt;/span&gt;        &lt;span class="kt"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&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="o"&gt;::&lt;/span&gt;&lt;span class="kt"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;      &lt;span class="kt"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="nb"&gt;now&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;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="no"&gt;RANGE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;audit_events_2026_11&lt;/span&gt; &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="no"&gt;OF&lt;/span&gt; &lt;span class="n"&gt;audit_events&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-11-01'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2026-12-01'&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;audit_events_tenant_created_idx&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;audit_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;audit_events_entity_idx&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;audit_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&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;Notes that matter in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;created_at DEFAULT now()&lt;/code&gt; uses the database clock inside the statement. Do not trust the app server’s wall clock as the legal timestamp.&lt;/li&gt;
&lt;li&gt;Store diffs or snapshots deliberately. Full &lt;code&gt;before_state&lt;/code&gt;/&lt;code&gt;after_state&lt;/code&gt; is simplest for support; large blobs belong in object storage with a pointer in &lt;code&gt;metadata&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Partitioning keeps indexes manageable and makes retention a partition detach, not a million-row DELETE.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Triggers vs application-layer writes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Atomic with business write?&lt;/th&gt;
&lt;th&gt;Covers raw SQL / admin?&lt;/th&gt;
&lt;th&gt;App complexity&lt;/th&gt;
&lt;th&gt;Typical failure&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;App INSERT in same DB transaction&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Low–medium&lt;/td&gt;
&lt;td&gt;Forgot one code path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AFTER trigger on base tables&lt;/td&gt;
&lt;td&gt;Yes (same txn)&lt;/td&gt;
&lt;td&gt;Yes for those tables&lt;/td&gt;
&lt;td&gt;Medium (payload shape)&lt;/td&gt;
&lt;td&gt;Trigger recursion, noisy columns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security definer function only&lt;/td&gt;
&lt;td&gt;Yes if called in txn&lt;/td&gt;
&lt;td&gt;Depends who can call&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Bypass by direct table write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Async queue after commit&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Low until incidents&lt;/td&gt;
&lt;td&gt;Lost events after crash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External SIEM only&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Ops-heavy&lt;/td&gt;
&lt;td&gt;Gap between DB and SIEM&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My default for Laravel SaaS: application write in the same transaction for domain actions you control, plus triggers on a small set of high-risk tables (&lt;code&gt;memberships&lt;/code&gt;, &lt;code&gt;api_keys&lt;/code&gt;, &lt;code&gt;billing_accounts&lt;/code&gt;) so console and migration mistakes still leave a trail. Do not trigger-log every &lt;code&gt;updated_at&lt;/code&gt; touch on high-churn tables—you will drown in noise and cost.&lt;/p&gt;

&lt;p&gt;Laravel write path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateOrderStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$actor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$actor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;only&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'total_cents'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'currency'&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="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;forceFill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="p"&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;save&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="nc"&gt;AuditEvent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&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;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'id'&lt;/span&gt;            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Str&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;uuid&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="s1"&gt;'tenant_id'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'actor_user_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$actor&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'actor_type'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'action'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'order.status_changed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'entity_type'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Order'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'entity_id'&lt;/span&gt;     &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'request_id'&lt;/span&gt;    &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&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="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'request_id'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'ip_inet'&lt;/span&gt;       &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&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;ip&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="s1"&gt;'before_state'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'after_state'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;only&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'total_cents'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'currency'&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="s1"&gt;'metadata'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'channel'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'admin_ui'&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="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fresh&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trigger sketch for a sensitive table (runs even if someone updates via &lt;code&gt;psql&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="nf"&gt;audit_memberships&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt;
&lt;span class="no"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="no"&gt;SECURITY&lt;/span&gt; &lt;span class="no"&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="n"&gt;public&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; $$
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;audit_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actor_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;before_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;after_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;metadata&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="no"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'system'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;TG_OP&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'.membership'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'Membership'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;NEW&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;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="no"&gt;TG_OP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'INSERT'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="nf"&gt;to_jsonb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;OLD&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="nf"&gt;to_jsonb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;jsonb_build_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'table'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;TG_TABLE_NAME&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;RETURN&lt;/span&gt; &lt;span class="no"&gt;NEW&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="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;trg_audit_memberships&lt;/span&gt;
&lt;span class="no"&gt;AFTER&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;memberships&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="no"&gt;EACH&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="no"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="nf"&gt;audit_memberships&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;Revoke UPDATE/DELETE on &lt;code&gt;audit_events&lt;/code&gt; from the app role; grant INSERT/SELECT only. The trigger function owner must be a role that &lt;em&gt;can&lt;/em&gt; insert, while the app role still cannot mutate history.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4lc6j0cv2elfs8cjx21u.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4lc6j0cv2elfs8cjx21u.jpg" alt="Sealed archival ledger books stacked in a metal cage under cool archive lighting, metaphor for append-only audit records that cannot be rewritten" width="800" height="600"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;AI-generated image.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Privilege model, retention, and cost
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;audit_events&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;app_runtime&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;INSERT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;audit_events&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;app_runtime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- partitions inherit; verify with \dp after each new partition&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Retention is a compliance decision expressed as ops:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Detach partitions older than the policy window into cold storage or an archive schema.&lt;/li&gt;
&lt;li&gt;Never &lt;code&gt;DELETE FROM audit_events WHERE created_at &amp;lt; …&lt;/code&gt; with the app role.&lt;/li&gt;
&lt;li&gt;Document who can run detach/drop and require dual control for destruction.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cost trade-offs: JSONB snapshots grow with column width and churn. Cap logged columns, sample low-risk fields, or store hashes of large text. Indexes on &lt;code&gt;(tenant_id, created_at)&lt;/code&gt; and entity lookups are enough for support; avoid indexing every JSONB path “just in case.”&lt;/p&gt;

&lt;p&gt;Related reliability themes show up when inventory and billing also need durable facts: see &lt;a href="https://dev.to/muhammadalimughal/multi-warehouse-inventory-a-stock-ledger-that-balances-3ka8"&gt;multi-warehouse inventory: a stock ledger that balances&lt;/a&gt; and &lt;a href="https://dev.to/muhammadalimughal/idempotent-jobs-exactly-once-effects-designing-safe-retries-6hh"&gt;idempotent jobs and exactly-once effects&lt;/a&gt;. Zero-downtime schema work that creates partitions safely pairs with &lt;a href="https://dev.to/muhammadalimughal/zero-downtime-database-migrations-in-laravel-nodejs-saas-expand-contract-lock-timeouts-6lp"&gt;expand/contract migrations&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure modes I design for explicitly
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;App role is also table owner.&lt;/strong&gt; Ownership bypasses REVOKE. Own tables as a migration role; runtime connects as &lt;code&gt;app_runtime&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Correction culture.&lt;/strong&gt; Support wants to “fix a typo” in an audit row. Refuse. Append &lt;code&gt;audit.correction&lt;/code&gt; with a pointer to the prior event id in &lt;code&gt;metadata&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PII in snapshots.&lt;/strong&gt; Before/after may contain emails or phone numbers. Align retention with privacy policy; redact or tokenize sensitive keys at write time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clock skew across replicas.&lt;/strong&gt; Use &lt;code&gt;DEFAULT now()&lt;/code&gt; in Postgres, not &lt;code&gt;Carbon::now()&lt;/code&gt; from three app nodes, as the authoritative audit timestamp.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partition not created for the new month.&lt;/strong&gt; Inserts fail at month boundary. Automate next-partition creation in migrations or a scheduled job days ahead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trigger recursion.&lt;/strong&gt; Auditing the audit table, or cascading updates that re-fire, can lock the system. Keep triggers narrow and never audit &lt;code&gt;audit_events&lt;/code&gt; itself with the same function.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;code&gt;audit_events&lt;/code&gt; append-only: app role has INSERT/SELECT only&lt;/li&gt;
&lt;li&gt;[ ] Business write and audit INSERT share one transaction (or AFTER trigger)&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;tenant_id&lt;/code&gt;, actor, action, entity, before/after, &lt;code&gt;created_at&lt;/code&gt; present&lt;/li&gt;
&lt;li&gt;[ ] Partitions by month (or week at high volume) with ahead-of-time creation&lt;/li&gt;
&lt;li&gt;[ ] High-risk tables also covered by triggers&lt;/li&gt;
&lt;li&gt;[ ] Corrections are new events, never UPDATE history&lt;/li&gt;
&lt;li&gt;[ ] Retention = detach/archive with dual control, not app DELETEs&lt;/li&gt;
&lt;li&gt;[ ] Alert when insert error rate spikes (missing partition, privilege drift)&lt;/li&gt;
&lt;li&gt;[ ] Support UI reads audit by tenant + entity; no “edit history” button&lt;/li&gt;
&lt;li&gt;[ ] Documented &lt;a href="https://looporigin.com/services/server-setup-maintenance?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=postgres-immutable-audit-trails-saas" rel="noopener noreferrer"&gt;server/ops&lt;/a&gt; runbook for privilege and partition jobs&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is soft delete enough for an audit trail?
&lt;/h3&gt;

&lt;p&gt;No. Soft delete still allows privileged DELETE, UPDATE of the row, and silent privilege escalation. Append-only privileges plus compensating events are the durable model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should every column change create an audit row?
&lt;/h3&gt;

&lt;p&gt;No. Audit security- and money-relevant actions and fields. Logging every &lt;code&gt;updated_at&lt;/code&gt; on a high-churn table burns storage and buries signal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I put audit data in a separate database?
&lt;/h3&gt;

&lt;p&gt;You can for analytics copies, but the authoritative insert should share a transaction with the business write when possible. Cross-database dual writes reintroduce lost-event failures.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I handle GDPR erasure requests?
&lt;/h3&gt;

&lt;p&gt;Plan erasure as a controlled process: redact PII fields in archived partitions under legal review, or replace payloads with tombstone markers while preserving non-PII action metadata—never a casual DELETE from the app.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do ORMs make this automatic?
&lt;/h3&gt;

&lt;p&gt;No. Eloquent observers help only for paths that use the model. Raw queries, jobs, and &lt;code&gt;psql&lt;/code&gt; bypass observers unless you also use triggers or a shared write service.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about event sourcing instead of an audit table?
&lt;/h3&gt;

&lt;p&gt;Event sourcing can subsume audit if every state change is an immutable event. Most SaaS teams need a pragmatic audit table first; full event sourcing is a larger domain redesign.&lt;/p&gt;

&lt;p&gt;When we take on Laravel and Postgres builds at Loop Origin, immutable audit is one of the first architecture checks after tenancy and backups. Customers and auditors both ask the same question: can anyone rewrite yesterday? Your privileges and schema should make the honest answer “no.”&lt;/p&gt;




&lt;h3&gt;
  
  
  Follow &amp;amp; Connect
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/company/loop-origin" rel="noopener noreferrer"&gt;Loop Origin&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X:&lt;/strong&gt; &lt;a href="https://x.com/AliMughal887" rel="noopener noreferrer"&gt;Muhammad Ali Mughal (@AliMughal887)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/LoopOrigin" rel="noopener noreferrer"&gt;Loop Origin (@LoopOrigin)&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>architecture</category>
      <category>laravel</category>
    </item>
  </channel>
</rss>
