<?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: Posting Dude</title>
    <description>The latest articles on DEV Community by Posting Dude (@posting-dude).</description>
    <link>https://dev.to/posting-dude</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%2F3883134%2F75bed475-b966-4e63-8921-41160c6a93f3.jpg</url>
      <title>DEV Community: Posting Dude</title>
      <link>https://dev.to/posting-dude</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/posting-dude"/>
    <language>en</language>
    <item>
      <title>Chrome Blocked 8 eMudhra CA Keys on Oct 17. A Node Release From Oct 14 Still Trusts Them.</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Mon, 02 Nov 2026 05:28:12 +0000</pubDate>
      <link>https://dev.to/posting-dude/chrome-blocked-8-emudhra-ca-keys-on-oct-17-a-node-release-from-oct-14-still-trusts-them-4i55</link>
      <guid>https://dev.to/posting-dude/chrome-blocked-8-emudhra-ca-keys-on-oct-17-a-node-release-from-oct-14-still-trusts-them-4i55</guid>
      <description>&lt;p&gt;On Saturday, October 17, at 18:05 UTC, a researcher reported to the Chrome Root Program that the private keys of eight eMudhra intermediate CA certificates could be recovered by anyone. The keys had been generated with weak randomness. With one of them you can sign a certificate for any domain, and clients that still trust that intermediate will accept it.&lt;/p&gt;

&lt;p&gt;Chrome blocked the keys 100 minutes later with a CRLSet push. Firefox blocked them the same day. Microsoft followed on the 20th, Apple on the 22nd, and Chrome removed the root above them, emSign ECC Root CA - G3, from its root store on October 27.&lt;/p&gt;

&lt;p&gt;That covers browsers. Your backend is not a browser. It calls Stripe, sends webhooks and talks to your email provider through Node, Python or Go, and a lot of those don't read CRLSets. They check against a list of roots that shipped with the runtime or the package.&lt;/p&gt;

&lt;p&gt;So I tested it. Same Debian 13 box, with the patched system &lt;code&gt;ca-certificates&lt;/code&gt; (20250419+deb13u1) installed. A Node binary released on October 14 still connected to a site signed by a compromised intermediate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened, briefly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Eight intermediates under emSign ECC Root CA - G3 had keys that could be recovered straight from the public certificates. eMudhra says it revoked all eight on October 18, that seven were never used in production, and that the one in production had issued zero certificates.&lt;/li&gt;
&lt;li&gt;No abuse has been reported. Cloudflare said it found no exploitation on its services, and the test page below says plainly that nobody knows whether non-browser clients were targeted. Browsers also enforce Certificate Transparency, so a forged cert that was never logged fails there anyway.&lt;/li&gt;
&lt;li&gt;Most non-browser clients check neither revocation nor CT. For them, the only fix is a trust store that no longer includes those roots.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dennis Jackson from Mozilla put up &lt;a href="https://emudhra.we-have-a-problem.com/" rel="noopener noreferrer"&gt;a test page with live endpoints signed by the compromised intermediates&lt;/a&gt;, plus a list of which packages have shipped fixes. If your client connects and gets the text "That's bad!", it still trusts them. The &lt;code&gt;-noct&lt;/code&gt; hosts use leaf certs that were never logged to CT, which is the case an attacker would actually use.&lt;/p&gt;

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

&lt;p&gt;A 13-line script that calls &lt;code&gt;fetch()&lt;/code&gt; on the four test hosts:&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;// check-emudhra.mjs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hosts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dv-g3b&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dv-g3b-aaa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dv-g3b-noct&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dv-g3b-aaa-noct&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`node &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;hosts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-emudhra.we-have-a-problem.com/`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&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="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&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;15000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; TRUSTED   &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; rejected  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cause&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;Real output, run this morning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node v24.22.1
dv-g3b           rejected  CERT_REVOKED
dv-g3b-aaa       rejected  CERT_REVOKED
dv-g3b-noct      rejected  CERT_REVOKED
dv-g3b-aaa-noct  rejected  CERT_REVOKED

node v24.22.0
dv-g3b           TRUSTED   200 "Your client successfully connected to th"
dv-g3b-aaa       rejected  UNABLE_TO_GET_ISSUER_CERT_LOCALLY
dv-g3b-noct      TRUSTED   200 "Your client successfully connected to th"
dv-g3b-aaa-noct  rejected  UNABLE_TO_GET_ISSUER_CERT_LOCALLY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full body of that 200 is "Your client successfully connected to the site. That's bad!". Node 24.22.0 came out on October 14. Node 24.22.1 is the October 21 security release, which updated the bundled roots to NSS 3.131.1 and added a check that rejects chains containing blocked public keys. That second change is most likely where the &lt;code&gt;CERT_REVOKED&lt;/code&gt; comes from.&lt;/p&gt;

&lt;p&gt;I ran the same thing in Python with &lt;code&gt;requests&lt;/code&gt;, which uses &lt;code&gt;certifi&lt;/code&gt;'s bundle and not the OS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;certifi 2026.10.23
dv-g3b           rejected  unable to get local issuer certificate
dv-g3b-noct      rejected  unable to get local issuer certificate

certifi 2026.07.22
dv-g3b           TRUSTED   200 'Your client successfully connected to th'
dv-g3b-noct      TRUSTED   200 'Your client successfully connected to th'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The two &lt;code&gt;-aaa&lt;/code&gt; hosts, which use Sectigo cross-signs, were rejected by every client on this box, old or new, so I've left them out here.)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;curl&lt;/code&gt; on the same machine rejected all four, because it uses the patched system bundle. So the OS was fixed and two of the three clients on it weren't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why patching the OS didn't help
&lt;/h2&gt;

&lt;p&gt;Node ships its own copy of the Mozilla root list inside the binary. &lt;code&gt;requests&lt;/code&gt; reads &lt;code&gt;certifi&lt;/code&gt;'s &lt;code&gt;cacert.pem&lt;/code&gt; out of your virtualenv. Updating &lt;code&gt;ca-certificates&lt;/code&gt; touches neither.&lt;/p&gt;

&lt;p&gt;One trap I didn't expect: &lt;code&gt;node --use-system-ca&lt;/code&gt; didn't help on the old binary either. It still trusted the bad host. The Node docs say that flag uses the system store "along with" the bundled one, so the old bundled root is still there. &lt;code&gt;--use-openssl-ca&lt;/code&gt;, which swaps the bundled store for OpenSSL's system store, did reject all four on the old binary. Upgrading Node is still the real fix. The flag just tells you where the trust is coming from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the old copies are hiding
&lt;/h2&gt;

&lt;p&gt;For a small SaaS or a side project, I'd check these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Docker images built before October 21.&lt;/strong&gt; The Node or Python inside is whatever was current at build time. If you pin &lt;code&gt;node:24-slim&lt;/code&gt; by digest, it doesn't move until you move it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything on Node 20.&lt;/strong&gt; It's end-of-life, so it doesn't get the new root list at all. Vercel's advisory says the same thing and tells people to move to 22 or newer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A pinned &lt;code&gt;certifi&lt;/code&gt;&lt;/strong&gt; in &lt;code&gt;requirements.txt&lt;/code&gt;, &lt;code&gt;poetry.lock&lt;/code&gt; or &lt;code&gt;uv.lock&lt;/code&gt;. Patched from 2026.10.19.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI runners and self-hosted cron boxes&lt;/strong&gt;, which tend to drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your Mac.&lt;/strong&gt; As of the test page's October 28 update, the &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;git&lt;/code&gt; and &lt;code&gt;python3&lt;/code&gt; Apple ships still trust the compromised certificates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project you haven't touched since summer is the most likely to still have the old list. If that's you, I wrote a list of &lt;a href="https://sidehunt.io/studio/how-to-restart-an-abandoned-side-project?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=emudhra-trust-store" rel="noopener noreferrer"&gt;what to check first when you restart an abandoned side project&lt;/a&gt;, and an expired runtime is one of the items on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  An 18-line check for any machine or image
&lt;/h2&gt;

&lt;p&gt;This prints &lt;code&gt;1&lt;/code&gt; if &lt;code&gt;emSign ECC Root CA - G3&lt;/code&gt; is still in the system bundle, in the &lt;code&gt;node&lt;/code&gt; you're running, or in your &lt;code&gt;certifi&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# roots-check.sh: 1 = root still trusted (bad), 0 = gone&lt;/span&gt;
&lt;span class="nv"&gt;NODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NODE&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;node&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;PY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PY&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;python3&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;needle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'emSign ECC Root CA - G3'&lt;/span&gt;

count_in_bundle&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  openssl crl2pkcs7 &lt;span class="nt"&gt;-nocrl&lt;/span&gt; &lt;span class="nt"&gt;-certfile&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | openssl pkcs7 &lt;span class="nt"&gt;-print_certs&lt;/span&gt; &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'^subject='&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$needle&lt;/span&gt;&lt;span class="s2"&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;"system bundle:          &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;count_in_bundle /etc/ssl/certs/ca-certificates.crt&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"node &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;$NODE&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; bundled: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;$NODE&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'
  const { X509Certificate } = require("crypto");
  const hits = require("tls").rootCertificates
    .filter(pem =&amp;gt; new X509Certificate(pem).subject.includes(process.argv[1]));
  console.log(hits.length)'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$needle&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;CF&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;$PY&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import certifi; print(certifi.where())'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"certifi &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;$PY&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import certifi; print(certifi.__version__)'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;:     &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;count_in_bundle &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CF&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output for the current versions and then the old ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;system bundle:          0
node v24.22.1 bundled: 0
certifi 2026.10.23:     0

system bundle:          0
node v24.22.0 bundled: 1
certifi 2026.07.22:     1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To check a container, mount the script and run it inside, something like &lt;code&gt;docker run --rm -v "$PWD:/c" your-image bash /c/roots-check.sh&lt;/code&gt;. Slim images often lack openssl or python, so just keep the line that applies (for a Node image, the &lt;code&gt;node -e&lt;/code&gt; part is all you need).&lt;/p&gt;

&lt;h2&gt;
  
  
  Is your own site's certificate affected?
&lt;/h2&gt;

&lt;p&gt;This is the other half, and for most indie sites the answer is no. If your cert comes from Let's Encrypt or Google Trust Services, like most small sites, nothing changes for you. It matters if you bought an ECC certificate that chains to emSign ECC Root CA - G3. The University of Michigan, for example, had to replace certificates issued through CERTInext with ECC keys, and its sites behind Cloudflare in Full (strict) mode started returning 526 errors on October 20.&lt;/p&gt;

&lt;p&gt;To see which root you're on, look at the issuer line and every row of the chain, not just the expiry date. &lt;code&gt;openssl s_client -connect yourdomain.com:443 -showcerts&lt;/code&gt; shows them, or you can paste the domain into &lt;a href="https://uno.directory/tools/ssl-checker?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=emudhra-trust-store" rel="noopener noreferrer"&gt;a free SSL checker that lists the issuer and the full chain&lt;/a&gt;. If "emSign" shows up anywhere in that chain, get a new certificate from a different CA before your visitors start seeing warnings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix list
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Node: 22.23.4, 24.22.1 or 26.12.1 or later. Get off Node 20.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;certifi&lt;/code&gt; 2026.10.19 or later, and update the lockfile too.&lt;/li&gt;
&lt;li&gt;Rebuild images with &lt;code&gt;--pull --no-cache&lt;/code&gt; so you don't reuse the old layer, then redeploy. Restart long-running processes too. A Node process that's been up since September is still running the old binary with the old roots.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;check-emudhra.mjs&lt;/code&gt; (or &lt;code&gt;curl&lt;/code&gt; on the test host) from inside the deployed thing, not from your laptop. Cloudflare's write-up makes the same point: a passing browser test doesn't mean your background worker is fixed.&lt;/li&gt;
&lt;li&gt;If something starts failing with certificate errors after you patch, do not "fix" it with &lt;code&gt;NODE_TLS_REJECT_UNAUTHORIZED=0&lt;/code&gt; or &lt;code&gt;verify=False&lt;/code&gt;. That turns off the protection you just installed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the second certificate change this month that only shows up when you check what's actually running instead of what the config says. The other one is &lt;a href="https://dev.to/posting-dude/lets-encrypt-goes-to-64-day-certificates-in-february-grep-your-server-for-60-80-and-83-first-lkn"&gt;Let's Encrypt moving to 64-day certificates&lt;/a&gt;, where the danger is hardcoded renewal thresholds.&lt;/p&gt;

&lt;p&gt;If you pin your base image by digest (which you should for other reasons), what makes you rebuild it? A schedule, Renovate/Dependabot bumping the digest, or only when something breaks?&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>node</category>
      <category>python</category>
    </item>
    <item>
      <title>Your Login Page's ?next= Check Probably Lets /%09/evil.com Through. 10 Strings to Test It With</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Sun, 01 Nov 2026 05:14:28 +0000</pubDate>
      <link>https://dev.to/posting-dude/your-login-pages-next-check-probably-lets-09evilcom-through-10-strings-to-test-it-with-eod</link>
      <guid>https://dev.to/posting-dude/your-login-pages-next-check-probably-lets-09evilcom-through-10-strings-to-test-it-with-eod</guid>
      <description>&lt;p&gt;Almost every login flow has one: &lt;code&gt;/login?next=/billing&lt;/code&gt;. After sign-in you send the user back to wherever they were. And almost every first version of the check looks like this:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;//&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads as airtight. It isn't. The problem is that &lt;strong&gt;your string check and the browser's URL parser disagree&lt;/strong&gt; about what the string means.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the check fails
&lt;/h2&gt;

&lt;p&gt;The WHATWG URL Standard tells browsers to strip ASCII tabs and newlines from the input before parsing. It also treats &lt;code&gt;\&lt;/code&gt; like &lt;code&gt;/&lt;/code&gt; for special schemes such as &lt;code&gt;https&lt;/code&gt;. So &lt;code&gt;/\t/evil.com&lt;/code&gt; becomes &lt;code&gt;//evil.com&lt;/code&gt; by the time it's parsed, which is a protocol-relative URL pointing at another host. &lt;code&gt;/\evil.com&lt;/code&gt; gets the same treatment.&lt;/p&gt;

&lt;p&gt;The query-string part matters too. When someone sends &lt;code&gt;?next=/%09/evil.com&lt;/code&gt;, &lt;code&gt;searchParams.get('next')&lt;/code&gt; hands your code the &lt;em&gt;decoded&lt;/em&gt; value, &lt;code&gt;"/\t/evil.com"&lt;/code&gt;. Your &lt;code&gt;startsWith('/')&lt;/code&gt; check passes, and the browser ends up on evil.com.&lt;/p&gt;

&lt;h2&gt;
  
  
  I ran 10 strings through two checks
&lt;/h2&gt;

&lt;p&gt;Here's a small Node script (Node v24). Check (a) is the naive one above. Check (b) parses the value against your own origin and only accepts it if the origin still matches:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ORIGIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.app&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;safeNext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ORIGIN&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;ORIGIN&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hash&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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last column shows where the browser actually ends up: &lt;code&gt;new URL(value, origin)&lt;/code&gt;, plus what happens if the value gets decoded one more time.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Naive check&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;new URL&lt;/code&gt; + origin check&lt;/th&gt;
&lt;th&gt;Resolves to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/%09/evil.com&lt;/code&gt; (raw)&lt;/td&gt;
&lt;td&gt;ALLOW&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/%09/evil.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;example.app (but evil.com once decoded)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;//evil.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;block&lt;/td&gt;
&lt;td&gt;block&lt;/td&gt;
&lt;td&gt;evil.com&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/\evil.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;ALLOW&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;block&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;evil.com&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;\\evil.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;block&lt;/td&gt;
&lt;td&gt;block&lt;/td&gt;
&lt;td&gt;evil.com&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/&amp;lt;TAB&amp;gt;/evil.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;ALLOW&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;block&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;evil.com&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https:evil.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;block&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/evil.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;example.app/evil.com&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/%2F%2Fevil.com&lt;/code&gt; (raw)&lt;/td&gt;
&lt;td&gt;ALLOW&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/%2F%2Fevil.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;example.app (but evil.com once decoded)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;///evil.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;block&lt;/td&gt;
&lt;td&gt;block&lt;/td&gt;
&lt;td&gt;evil.com&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/.evil.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ALLOW&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/.evil.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;example.app/.evil.com (harmless)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;javascript:alert(1)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;block&lt;/td&gt;
&lt;td&gt;block&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;javascript:&lt;/code&gt; URL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the real-world case from a query string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;searchParams.get: "/\t/evil.com" -&amp;gt; https://evil.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three takeaways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Two strings the naive check lets through really leave your site:&lt;/strong&gt; &lt;code&gt;/\evil.com&lt;/code&gt; and a tab-prefixed path. A tab is exactly what &lt;code&gt;?next=/%09/evil.com&lt;/code&gt; becomes after normal query decoding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The percent-encoded variants are only safe if nothing decodes them again.&lt;/strong&gt; &lt;code&gt;new URL&lt;/code&gt; keeps &lt;code&gt;/%09/evil.com&lt;/code&gt; same-origin, but one more &lt;code&gt;decodeURIComponent&lt;/code&gt; anywhere in your pipeline (a middleware, a "clean up" helper, a second redirect hop) turns it into evil.com. Validate the final value, then redirect with exactly what you validated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirect to the normalized path, not the raw input.&lt;/strong&gt; Return &lt;code&gt;u.pathname + u.search + u.hash&lt;/code&gt; from the parsed URL, not the original string.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  This bug has shipped in real auth tools
&lt;/h2&gt;

&lt;p&gt;oauth2-proxy had exactly this class of bug. The &lt;a href="https://github.com/advisories/GHSA-j7px-6hwj-hpjg" rel="noopener noreferrer"&gt;GHSA-j7px-6hwj-hpjg advisory (CVE-2020-11053)&lt;/a&gt; says a redirect URL with encoded whitespace characters (&lt;code&gt;%0a&lt;/code&gt;, &lt;code&gt;%0b&lt;/code&gt;, &lt;code&gt;%09&lt;/code&gt;, &lt;code&gt;%0d&lt;/code&gt;) could bypass its validation and redirect to any URL.&lt;/p&gt;

&lt;p&gt;It's also a classic subject line for unsolicited "bug bounty" emails. If one lands in your inbox, run the 10 strings above against your own check before you reply, and here's &lt;a href="https://indiehunt.io/studio/how-to-handle-security-bug-reports-and-beg-bounty-emails?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=login-next-open-redirect" rel="noopener noreferrer"&gt;how I'd tell a real security report from a beg bounty&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you're doing OAuth, the stakes are higher. RFC 9700 (OAuth 2.0 Security Best Current Practice) covers open redirectors in section 4.11 and requires exact string matching of redirect URIs in section 2.1, because a loose match plus an open redirect on the client can leak authorization codes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Framework notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js:&lt;/strong&gt; &lt;code&gt;redirect()&lt;/code&gt; sends whatever you give it. It doesn't check that the URL is on your origin, so run &lt;code&gt;safeNext()&lt;/code&gt; first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth.js:&lt;/strong&gt; the &lt;code&gt;redirect&lt;/code&gt; callback is where you control this. If you customize it, keep the "relative or same-origin only" logic rather than returning &lt;code&gt;url&lt;/code&gt; as-is.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Parse with &lt;code&gt;new URL(next, yourOrigin)&lt;/code&gt; and compare &lt;code&gt;origin&lt;/code&gt;. Don't use regex or &lt;code&gt;startsWith&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Fall back to a fixed path (like &lt;code&gt;/dashboard&lt;/code&gt;) when the check fails.&lt;/li&gt;
&lt;li&gt;Decode once, validate once, and redirect with the value you validated.&lt;/li&gt;
&lt;li&gt;Better still, use an allowlist of known paths. The &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP Unvalidated Redirects and Forwards Cheat Sheet&lt;/a&gt; recommends mapping to server-side values where you can.&lt;/li&gt;
&lt;li&gt;Add those 10 strings to your test suite. It takes five minutes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same idea applies to the other end of a login flow. A &lt;code&gt;?next=&lt;/code&gt; check stops an attacker sending your user somewhere else, and the support inbox has its own version of that, which I wrote up in &lt;a href="https://praneetbrar.hashnode.dev/change-login-email-support-request" rel="noopener noreferrer"&gt;why "please change my login email" is the riskiest ticket you'll get&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What does your &lt;code&gt;?next=&lt;/code&gt; check look like? I'd honestly like to see the variants people ship.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>A Canonical in Your Next.js Root Layout Lands on Every Page. 6 Ways a Canonical Quietly Cancels Itself</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Thu, 29 Oct 2026 05:25:48 +0000</pubDate>
      <link>https://dev.to/posting-dude/a-canonical-in-your-nextjs-root-layout-lands-on-every-page-6-ways-a-canonical-quietly-cancels-2l3f</link>
      <guid>https://dev.to/posting-dude/a-canonical-in-your-nextjs-root-layout-lands-on-every-page-6-ways-a-canonical-quietly-cancels-2l3f</guid>
      <description>&lt;p&gt;Last week on three of our launch sites (EarlyHunt, MakerHunt and SideHunt) every article under &lt;code&gt;/posts&lt;/code&gt; had a canonical tag pointing at the homepage. 14 articles, all quietly telling Google "I'm just a copy of the homepage". Nobody typed that on purpose. The canonical was set once in the Next.js root layout, and every route that didn't override it just inherited it. The pages looked fine, nothing errored, nothing in the logs. We fixed it on Oct 21 and each article now points at itself.&lt;/p&gt;

&lt;p&gt;That one got me curious about how many other ways a canonical can quietly cancel itself. So this morning I set up a tiny Next.js 16.4.3 test app, broke it on purpose a few ways, and wrote a small checker. Below is what I found, with the real output.&lt;/p&gt;

&lt;p&gt;Quick refresher first. Google's doc on &lt;a href="https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls" rel="noopener noreferrer"&gt;specifying a canonical URL&lt;/a&gt; lists three ways to state your preference, in order of strength: redirects (strong), &lt;code&gt;rel="canonical"&lt;/code&gt; (strong), sitemap inclusion (weak). The &lt;code&gt;rel="canonical"&lt;/code&gt; can go in two places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt; element in the HTML &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;Link: &amp;lt;url&amp;gt;; rel="canonical"&lt;/code&gt; HTTP response header&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The header is the only option for non-HTML files like PDFs. Google says pick one of the two, because using both "is more error prone" (you end up with one URL in the header and another in the HTML). And it's a hint, not a command. Google can still pick its own canonical.&lt;/p&gt;

&lt;p&gt;Here are the six ways I'd check, roughly in the order I'd expect to hit them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A canonical in a shared layout gets inherited by every child page
&lt;/h2&gt;

&lt;p&gt;This is the one that got us. In the App Router, metadata from &lt;code&gt;app/layout.js&lt;/code&gt; down to the &lt;code&gt;page.js&lt;/code&gt; is merged. The &lt;a href="https://nextjs.org/docs/app/api-reference/functions/generate-metadata" rel="noopener noreferrer"&gt;Next.js metadata docs&lt;/a&gt; say segments are "shallowly merged" and "duplicate keys are replaced based on their ordering". So if a child page never sets &lt;code&gt;alternates&lt;/code&gt;, it keeps the root's.&lt;/p&gt;

&lt;p&gt;My test root layout:&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;// app/layout.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;metadataBase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://acme.test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Acme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a blog post page that only sets a title:&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;// app/posts/[slug]/page.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateMetadata&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Post &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&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="c1"&gt;// no alternates&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After &lt;code&gt;next build &amp;amp;&amp;amp; next start&lt;/code&gt;:&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="err"&gt;== GET /
&amp;lt;link rel="canonical" href="https://acme.test"/&amp;gt;
== GET /posts/hello-world
&amp;lt;link rel="canonical" href="https://acme.test"/&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same canonical on the post as on the homepage. That's our bug, reproduced in two files.&lt;/p&gt;

&lt;p&gt;Fix: keep &lt;code&gt;metadataBase&lt;/code&gt; in the root layout (that part is fine and the docs recommend it there), but set the canonical per page:&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;// app/posts/[slug]/page.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateMetadata&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Post &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&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="na"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/posts/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&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="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 http"&gt;&lt;code&gt;&lt;span class="err"&gt;== GET /fixed/hello-world
&amp;lt;link rel="canonical" href="https://acme.test/fixed/hello-world"/&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And put the homepage's &lt;code&gt;canonical: '/'&lt;/code&gt; in &lt;code&gt;app/page.js&lt;/code&gt;, not the layout. This isn't only a Next.js thing btw. Any framework where a shared layout or theme writes the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; can do it. Google's old 2013 post on rel=canonical mistakes describes people copying a page template "without thinking to change the target of the rel=canonical".&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Adding hreflang (or anything under &lt;code&gt;alternates&lt;/code&gt;) deletes the inherited canonical
&lt;/h2&gt;

&lt;p&gt;This one surprised me more. Because the merge is shallow, &lt;code&gt;alternates&lt;/code&gt; is one key. If a page sets &lt;code&gt;alternates.languages&lt;/code&gt; and nothing else, the root's &lt;code&gt;alternates.canonical&lt;/code&gt; is gone, not merged.&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;// app/lang/page.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Lang&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;languages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;de-DE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/de/lang&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;== GET /lang
&amp;lt;link rel="alternate" hrefLang="de-DE" href="https://acme.test/de/lang"/&amp;gt;
canonical count: 0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero canonicals. Here it's kind of a lucky accident, since the inherited one was wrong anyway. But flip it around: if your layout had a correct per-route canonical helper and someone adds hreflang to one page, that page silently loses it. Same goes for &lt;code&gt;openGraph&lt;/code&gt;, the docs show a child &lt;code&gt;openGraph&lt;/code&gt; replacing the whole parent object. If you set &lt;code&gt;alternates&lt;/code&gt; anywhere below the root, always set &lt;code&gt;canonical&lt;/code&gt; in the same object.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Relative paths, and the &lt;code&gt;./&lt;/code&gt; thing
&lt;/h2&gt;

&lt;p&gt;Google recommends absolute URLs. Relative ones are supported but "can cause problems in the long run (for example, if you unintentionally allow your testing site to be crawled)". The 2013 post has a nice example: writing &lt;code&gt;example.com/cupcake.html&lt;/code&gt; without &lt;code&gt;https://&lt;/code&gt; makes it a relative path, so the canonical becomes &lt;code&gt;https://example.com/example.com/cupcake.html&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In Next.js you write relative paths and &lt;code&gt;metadataBase&lt;/code&gt; turns them absolute. Per the docs, a relative URL field without &lt;code&gt;metadataBase&lt;/code&gt; is a build error, which is a good guardrail. One gotcha: the docs' URL composition table says &lt;code&gt;./&lt;/code&gt; resolves to the bare &lt;code&gt;metadataBase&lt;/code&gt; (&lt;code&gt;https://acme.com&lt;/code&gt;). In my test on 16.4.3, &lt;code&gt;canonical: './'&lt;/code&gt; on &lt;code&gt;/dotslash&lt;/code&gt; resolved to the page's own path:&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="err"&gt;== GET /dotslash
&amp;lt;link rel="canonical" href="https://acme.test/dotslash"/&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So either the table is about something else or the behaviour changed, I didn't dig further. Either way I wouldn't rely on &lt;code&gt;./&lt;/code&gt;. Write the full path.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Two canonical tags
&lt;/h2&gt;

&lt;p&gt;Google's 2013 post is blunt here: "Specify no more than one rel=canonical for a page. When more than one is specified, all rel=canonical links will be ignored." It mentions SEO plugins adding a default one without the site owner knowing.&lt;/p&gt;

&lt;p&gt;In a Next.js app, the easy way to get two is having &lt;code&gt;alternates.canonical&lt;/code&gt; in metadata and also a hand-written &lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt; somewhere in a layout from before you moved to the Metadata API. Or one in HTML and a different one in the &lt;code&gt;Link&lt;/code&gt; header from your CDN or host config. Count them, don't just grab the first one. A lot of quick checks do &lt;code&gt;$('link[rel=canonical]').attr('href')&lt;/code&gt;, which only reads the first match, so two tags pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A canonical that points at a 404, a redirect, or a noindex page
&lt;/h2&gt;

&lt;p&gt;The tag can be perfect and the target can still be broken. Google's 2013 checklist: "Double-check that your rel=canonical target exists (it's not an error or soft 404)" and "Verify the rel=canonical target doesn't contain a noindex robots meta tag". A canonical to a URL that redirects is also a mixed signal. You're saying "the real one is over there" and over there says "no, it's somewhere else".&lt;/p&gt;

&lt;p&gt;Common ways it happens on small sites: canonical still points at the staging domain, you renamed a route and redirected it but the canonical helper builds the old path, or http vs https / www vs non-www mismatch between the canonical and the URL you actually serve.&lt;/p&gt;

&lt;p&gt;You can check this with curl (&lt;code&gt;curl -s -o /dev/null -w "%{http_code}" &amp;lt;canonical-url&amp;gt;&lt;/code&gt;, no &lt;code&gt;-L&lt;/code&gt;), or if you'd rather paste a URL, &lt;a href="https://auraplusplus.com/tools/canonical-checker?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=bip-aura-canonical-tag-checker" rel="noopener noreferrer"&gt;a free canonical tag checker I built on Aura++&lt;/a&gt; requests the canonical target and flags anything that isn't a 200. It also reads the &lt;code&gt;Link&lt;/code&gt; header and compares against og:url. No signup.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The canonical ends up in &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is the newest one and I'm honestly not sure how much it matters, so here's what I can show.&lt;/p&gt;

&lt;p&gt;Since Next.js 15.2, &lt;code&gt;generateMetadata&lt;/code&gt; can stream. From the docs: when it resolves, "the resulting metadata tags are appended to the &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; tag". Next.js says they "have verified that metadata is interpreted correctly by bots that execute JavaScript and inspect the full DOM (e.g. Googlebot)". For "HTML-limited bots" (it detects these by user agent, e.g. &lt;code&gt;facebookexternalhit&lt;/code&gt;) metadata stays in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Google's canonical doc, on the other hand, says the element "is only accepted if it appears in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; section of the HTML". &lt;/p&gt;

&lt;p&gt;I tested a dynamic route whose &lt;code&gt;generateMetadata&lt;/code&gt; awaits &lt;code&gt;searchParams&lt;/code&gt;, with three user agents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/stream UA=curl/8: canonical byte 5160, &amp;lt;/head&amp;gt; byte 590 -&amp;gt; IN BODY
/stream UA=Mozilla/5.0 (compatible; Googlebot/2.1; ...): IN BODY
/stream UA=facebookexternalhit/1.1: canonical byte 544, &amp;lt;/head&amp;gt; byte 666 -&amp;gt; IN HEAD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also loaded it in headless Chrome and checked the rendered DOM. The canonical was still inside &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; after JavaScript ran. Static and SSG pages (like &lt;code&gt;/posts/hello-world&lt;/code&gt; above) had it in the head.&lt;/p&gt;

&lt;p&gt;I can't see inside Googlebot, so I won't tell you this breaks anything. Next.js says it's fine, Google's doc wording says head. If you don't want to bet on it, the docs give &lt;code&gt;htmlLimitedBots&lt;/code&gt; in &lt;code&gt;next.config.js&lt;/code&gt;. I set it to &lt;code&gt;/Googlebot/&lt;/code&gt; and re-ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;htmlLimitedBots=/Googlebot/ UA=Mozilla/5.0 (compatible; Googlebot/2.1; ...) -&amp;gt; IN HEAD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The docs warn this "could lead to longer response times", so it's a trade off. Another option is making the metadata not depend on request data so the route can prerender.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: og:url that disagrees
&lt;/h2&gt;

&lt;p&gt;og:url isn't one of Google's canonical signals. But if your canonical and og:url disagree, it usually means two pieces of code build "the URL of this page" separately, and one of them is wrong. That's a good place to look for bugs #1 and #3.&lt;/p&gt;

&lt;h2&gt;
  
  
  PDFs: the Link header
&lt;/h2&gt;

&lt;p&gt;For a PDF there's no &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, so the header is the only way. In Next.js you can add it in &lt;code&gt;next.config.js&lt;/code&gt;:&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="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/whitepaper.pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Link&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://acme.test/whitepaper&amp;gt;; rel="canonical"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="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 http"&gt;&lt;code&gt;&lt;span class="err"&gt;$ curl -sI localhost:3125/whitepaper.pdf
&lt;/span&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="ne"&gt;OK&lt;/span&gt;
&lt;span class="na"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;https://acme.test/whitepaper&amp;gt;; rel="canonical"&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/pdf&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just don't also send a header on HTML pages that already have a tag, that's back to #4.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quick check
&lt;/h2&gt;

&lt;p&gt;Fastest version is two curls. Run them on a deep page, not the homepage, because the homepage is where a wrong inherited canonical looks right. Here on github.com just to show the output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://github.com/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-io&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;link[^&amp;gt;]*rel="canonical"[^&amp;gt;]*&amp;gt;'&lt;/span&gt;
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://github.com/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'^link:'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"canonical"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://github.com"&lt;/span&gt; &lt;span class="na"&gt;data-turbo-transient&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
# (second command printed nothing, so no Link header)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For more than eyeballing, here's the small Node script I used (Node 18+, no dependencies). It reads server HTML only, it doesn't run JavaScript, which is also how I wanted it, so it tells you where the tag is before any script moves things around.&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;// check-canonical.mjs  usage: node check-canonical.mjs &amp;lt;url&amp;gt; [url...]&lt;/span&gt;
&lt;span class="c1"&gt;// Node 18+, no dependencies. Reads server HTML only (no JavaScript run).&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;attr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RegExp&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="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;s*=&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;s*["']([^"']*)["']`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;i&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[])[&lt;/span&gt;&lt;span class="mi"&gt;1&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;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&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="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;follow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user-agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canonical-check/1.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;finalUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headEnd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;head&amp;gt;/i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;problems&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="c1"&gt;// 1. canonical in the HTML (anywhere, so we can tell head vs body)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;link&lt;/span&gt;&lt;span class="se"&gt;\b[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;/gi&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="sr"&gt;/rel&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*=&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]?&lt;/span&gt;&lt;span class="sr"&gt;canonical&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]?&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;href&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;inHead&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;headEnd&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;headEnd&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. canonical in the HTTP Link header&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;linkHdr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;link&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hdr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;linkHdr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*;&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;,&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*rel="&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;canonical"&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;/i&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. og:url&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;og&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;meta&lt;/span&gt;&lt;span class="se"&gt;\b[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;/gi&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="sr"&gt;/property&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*=&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;og:url&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ogUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;og&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;og&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`  status &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;finalUrl&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;` (ended at &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;finalUrl&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="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`  html canonical: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inHead&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; [IN BODY]&lt;/span&gt;&lt;span class="dl"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; | &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`  header canonical: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;hdr&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`  og:url: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ogUrl&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hdr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no canonical declared&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; canonical tags (Google may ignore all of them)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inHead&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canonical outside &amp;lt;head&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hdr&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hdr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;finalUrl&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;finalUrl&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;header and html canonical disagree&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;hdr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&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="sr"&gt;/^https&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\/\/&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`relative canonical "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;raw&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;finalUrl&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;norm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;finalUrl&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`points elsewhere: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;target&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;manual&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user-agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canonical-check/1.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`  canonical target status: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;location&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;location&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`canonical target returns &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ogUrl&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ogUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;finalUrl&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`og:url differs: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ogUrl&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="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;problems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;  ! &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;  ok, no findings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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;try&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;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n  fetch failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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="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 on three URLs this morning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node check-canonical.mjs https://example.com/ https://github.com/ https://www.github.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://example.com/
  status 200
  html canonical: none
  header canonical: none
  og:url: none
  ! no canonical declared

https://github.com/
  status 200
  html canonical: https://github.com
  header canonical: none
  og:url: https://github.com/
  canonical target status: 200
  ok, no findings

https://www.github.com/
  status 200 (ended at https://github.com/)
  html canonical: https://github.com
  header canonical: none
  og:url: https://github.com/
  canonical target status: 200
  ok, no findings
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;example.com doesn't declare a canonical at all. That's not wrong, Google says you'll "likely do just fine" without one and it'll pick the best version itself. github.com is the textbook version: one absolute self canonical, og:url matches (the trailing slash difference is normalised in the script), target returns 200. And &lt;a href="http://www.github.com" rel="noopener noreferrer"&gt;www.github.com&lt;/a&gt; redirects to github.com, so the redirect and the canonical agree, which is what you want.&lt;/p&gt;

&lt;p&gt;Things the script doesn't do: it won't run JavaScript, it only follows the first &lt;code&gt;Link&lt;/code&gt; header canonical, and the og:url and "points elsewhere" checks treat a trailing slash as the same URL. Good enough to catch the six cases above on a deep page.&lt;/p&gt;

&lt;h2&gt;
  
  
  After you fix it, check what Google actually picked
&lt;/h2&gt;

&lt;p&gt;The tag is your vote, not the result. In Search Console's URL Inspection tool, look at "Google-selected canonical" for one of the affected pages. Google's troubleshooting doc also says re-evaluation takes time, even after a fix it "might hold pages in a duplicate cluster for up to two weeks". So don't panic if it doesn't flip the next day. There's a longer walkthrough on Aura++ about &lt;a href="https://auraplusplus.com/studio/how-to-rank-for-your-own-startup-name-on-google?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=nextjs-canonical-cancels-itself" rel="noopener noreferrer"&gt;reading URL Inspection when your own name doesn't show up on Google&lt;/a&gt;, and a leftover canonical is one of the first things it checks.&lt;/p&gt;

&lt;p&gt;For our 14 posts, it's too early to say if the fix changed anything in search, and I'm not going to guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I'm keeping
&lt;/h2&gt;

&lt;p&gt;Any time I add or change a layout, template or SEO helper that touches metadata, I open one deep page (a blog post, not the homepage) and check the canonical before calling it done. The homepage is exactly where a wrong inherited canonical looks correct.&lt;/p&gt;

&lt;p&gt;Have you ever checked what canonical your blog posts actually point to? And if you're on Next.js 15.2+ with dynamic metadata, did you notice yours landing in &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt;, and did you change anything because of it?&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>seo</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>One Old CNAME Let Spammers Send Mail as marthastewart.msn.com. Check Your Old Subdomains.</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Wed, 28 Oct 2026 05:28:21 +0000</pubDate>
      <link>https://dev.to/posting-dude/one-old-cname-let-spammers-send-mail-as-marthastewartmsncom-check-your-old-subdomains-16b8</link>
      <guid>https://dev.to/posting-dude/one-old-cname-let-spammers-send-mail-as-marthastewartmsncom-check-your-old-subdomains-16b8</guid>
      <description>&lt;p&gt;In February 2024 Guardio Labs published a report on a spam operation they called SubdoMailing. One of their examples was mail sent from &lt;code&gt;marthastewart.msn.com&lt;/code&gt;. It passed SPF. It passed DMARC. It was sent from a server in Kyiv that Microsoft had nothing to do with.&lt;/p&gt;

&lt;p&gt;The reason was one DNS record. Around 2001, MSN ran a sweepstakes with Martha Stewart. The subdomain &lt;code&gt;marthastewart.msn.com&lt;/code&gt; had a CNAME pointing at an outside domain, &lt;code&gt;msnmarthastewartsweeps.com&lt;/code&gt;. The promo ended, the outside domain expired, and nobody removed the CNAME. Per &lt;a href="https://guard.io/labs/subdomailing-thousands-of-hijacked-major-brand-subdomains-found-bombarding-users-with-millions" rel="noopener noreferrer"&gt;Guardio's write-up&lt;/a&gt;, nobody registered that domain again for 21 years, until September 2022, when someone bought it through Namecheap. Whoever owns the target of a CNAME controls what the subdomain says, including its SPF record. So they published an SPF record that authorized their own mail servers, and from then on their mail "came from" msn.com. Guardio counted over 8,000 domains abused with this trick or a close cousin of it (more below), including VMware, McAfee, eBay, CBS and Cornell.&lt;/p&gt;

&lt;p&gt;I checked today (Oct 28, 2026). The MSN record now points at Microsoft's own edge network, so that one is fixed. And &lt;code&gt;msnmarthastewartsweeps.com&lt;/code&gt; is unregistered again. You could buy it right now. It just doesn't matter anymore because nothing points at it.&lt;/p&gt;

&lt;p&gt;The reason I'm writing this for indie hackers and not for big companies: we are worse at this than MSN. We buy a domain per idea. We spin up &lt;code&gt;waitlist.&lt;/code&gt;, &lt;code&gt;docs.&lt;/code&gt;, &lt;code&gt;status.&lt;/code&gt;, &lt;code&gt;beta.&lt;/code&gt; and &lt;code&gt;old.&lt;/code&gt; subdomains on free hosts, then kill the project and delete the Vercel or Heroku app. The DNS record is in a different dashboard, so it stays.&lt;/p&gt;

&lt;p&gt;This post is the 15-minute check I'd run on every domain you still own, plus two small scripts. Everything here is read-only. It doesn't attack anything, it just looks up your own DNS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways a subdomain ends up pointing at someone else
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. A CNAME to a domain you no longer own.&lt;/strong&gt; This is the MSN case and it's the worst one, because no platform sits in the middle. If the target domain is unregistered, anyone can buy it for the price of a normal domain and gets full control of your subdomain: web content, a valid TLS certificate, and email (SPF/MX on the target domain).&lt;/p&gt;

&lt;p&gt;Indie version: &lt;code&gt;blog.mainproduct.com&lt;/code&gt; CNAME to &lt;code&gt;myoldblogidea.com&lt;/code&gt;, and you let &lt;code&gt;myoldblogidea.com&lt;/code&gt; lapse last spring because it was $15 a year for nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A CNAME to a deleted resource on a shared host.&lt;/strong&gt; You point &lt;code&gt;docs.yourapp.com&lt;/code&gt; at &lt;code&gt;yourapp-docs.herokuapp.com&lt;/code&gt; or an S3 bucket, then delete the app or bucket. Whether someone else can now claim that name depends on the provider. The community list can-i-take-over-xyz on GitHub (EdOverflow/can-i-take-over-xyz) tracks this. When I parsed its table today it had 76 services: 36 marked Vulnerable, 26 Not vulnerable, 14 Edge case. AWS S3, Surge.sh, Ghost and Bitbucket are on the Vulnerable side. GitHub Pages, Heroku, Vercel, Netlify, Shopify and Webflow are Edge case, which I read as "depends on how it was set up, and the provider has added some checks". Fly.io and Firebase are listed as Not vulnerable.&lt;/p&gt;

&lt;p&gt;The list says itself it's community-maintained with no guarantee of correctness, so read "Edge case" as "don't rely on it", not "safe".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. An SPF include (or MX, or NS) that points at something dead.&lt;/strong&gt; Guardio calls this one SPF-Takeover. Their example was swatch.com, whose SPF record had &lt;code&gt;a:directtoaccess.com&lt;/code&gt; in it, an old marketing-service domain that had been abandoned. &lt;code&gt;a:&lt;/code&gt; means "any IP that domain's A record points to may send as us". Buy the abandoned domain, point it at your server, and you're an authorized sender for the main domain. No CNAME needed. (Swatch's record no longer has it, I checked.) Same idea with an MX record pointing at a mail host on a dead domain, or a subdomain delegated with NS records to a DNS provider where you deleted the zone (DigitalOcean is on the can-i-take-over-xyz list for exactly that).&lt;/p&gt;

&lt;h2&gt;
  
  
  What someone actually gets
&lt;/h2&gt;

&lt;p&gt;Microsoft's Azure docs have a decent page on dangling DNS ("Prevent dangling DNS entries and avoid subdomain takeover"), and its risk list applies to any host:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;They can get a &lt;strong&gt;valid TLS certificate&lt;/strong&gt; for your subdomain. The padlock means nothing here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cookies scoped to your parent domain&lt;/strong&gt; (&lt;code&gt;Domain=yourapp.com&lt;/code&gt;) get sent to every subdomain, including the hijacked one. If your session cookie is set that way, a taken-over &lt;code&gt;old.yourapp.com&lt;/code&gt; receives it with every request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email that passes SPF/DMARC&lt;/strong&gt; under your name, like the MSN case. Your customers see your domain.&lt;/li&gt;
&lt;li&gt;Phishing pages on a URL your users have reason to trust.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cookie one is worth 2 minutes on its own. Open devtools on your app, look at the session cookie. If it has no Domain attribute it's host-only and sibling subdomains don't get it. If it shows &lt;code&gt;.yourapp.com&lt;/code&gt;, every subdomain you ever created is part of your login security. A &lt;code&gt;__Host-&lt;/code&gt; prefix on the cookie name forces host-only (the browser rejects it if a Domain attribute is set), so that's the cheap fix if you don't need cross-subdomain sessions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: list every hostname you have
&lt;/h2&gt;

&lt;p&gt;You need two lists, because each one misses things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your DNS provider's export.&lt;/strong&gt; Cloudflare, Namecheap, Route 53 and most others let you export the zone as a text file. That's what actually exists right now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Certificate transparency logs.&lt;/strong&gt; Every public TLS cert gets logged, so crt.sh shows names you had certs for, including ones you forgot. This catches the &lt;code&gt;beta2.&lt;/code&gt; you made at 1am in 2024:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://crt.sh/?q=%25.yourapp.com&amp;amp;output=json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[].name_value'&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'A-Z'&lt;/span&gt; &lt;span class="s1"&gt;'a-z'&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/^\*\.//'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; hosts.txt
&lt;span class="nb"&gt;echo &lt;/span&gt;yourapp.com &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; hosts.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;crt.sh is slow, about 20 seconds for a small domain when I tried it, and sometimes times out. Just run it again. Then paste in every name from the DNS export too and &lt;code&gt;sort -u hosts.txt -o hosts.txt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now the part people skip: also make a list of every &lt;strong&gt;domain&lt;/strong&gt; you own or used to own, with its renewal date. Not just the live product. The dead idea domains are exactly the ones that can turn into case 1, if anything still points at them. I'd keep this in the same sheet as a &lt;a href="https://sidehunt.io/studio/how-to-keep-your-side-project-monthly-bills-under-20?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=dangling-dns-subdomain-takeover" rel="noopener noreferrer"&gt;one-evening bill audit for a side project&lt;/a&gt;: every domain, renewal date, renewal price, and a keep-or-drop decision. The new rule I'd add to that sheet: a domain can only be dropped after you've searched your other zones for records pointing at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: check every name by hand once
&lt;/h2&gt;

&lt;p&gt;For one name, three commands tell you most of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short CNAME docs.yourapp.com          &lt;span class="c"&gt;# where does it point?&lt;/span&gt;
dig docs.yourapp.com A +noall +comments | &lt;span class="nb"&gt;grep &lt;/span&gt;status   &lt;span class="c"&gt;# NOERROR or NXDOMAIN?&lt;/span&gt;
curl &lt;span class="nt"&gt;-sk&lt;/span&gt; https://docs.yourapp.com/ | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 300        &lt;span class="c"&gt;# what does it serve?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the CNAME is there but the status is &lt;code&gt;NXDOMAIN&lt;/code&gt;, the target doesn't exist. That's dangling. Then check whether the target's domain is even registered. RDAP is the newer whois with a proper HTTP API, and rdap.org redirects you to the right registry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; https://rdap.org/domain/msnmarthastewartsweeps.com
&lt;span class="c"&gt;# 404&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;404 means no registration record. Anyone can buy it. For &lt;code&gt;.com&lt;/code&gt; that came back from Verisign's RDAP server when I ran it today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: check all of them with a script
&lt;/h2&gt;

&lt;p&gt;Doing that by hand for 40 names gets old, so here's the script. It needs only &lt;code&gt;dig&lt;/code&gt; and &lt;code&gt;curl&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# usage: ./check-dangling.sh hosts.txt   (one hostname per line)&lt;/span&gt;
&lt;span class="c"&gt;# needs: dig, curl. Read-only: DNS lookups, one RDAP lookup, one GET per host.&lt;/span&gt;
&lt;span class="c"&gt;# Fingerprints from github.com/EdOverflow/can-i-take-over-xyz (a match is a hint, not proof)&lt;/span&gt;
&lt;span class="nv"&gt;FP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'There isn.t a GitHub Pages site here|Site not found &amp;amp;middot; GitHub Pages|No such app|NoSuchBucket|DEPLOYMENT_NOT_FOUND|Not Found - Request ID|project not found|Repository not found|Sorry, this shop is currently unavailable'&lt;/span&gt;

apex&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;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/\.$//'&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'{print $(NF-1)"."$NF}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;  &lt;span class="c"&gt;# naive: wrong for .co.uk etc&lt;/span&gt;

&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; host&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;continue
  &lt;/span&gt;&lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;dig &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; A +noall +comments | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'status: [A-Z]*'&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;last&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;dig +short &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; A | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'\.$'&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;   &lt;span class="c"&gt;# last CNAME in the chain, if any&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$last&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"NXDOMAIN"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; &lt;span class="s2"&gt;"https://rdap.org/domain/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;apex &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$last&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"404"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
      &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"CRITICAL  &lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$last&lt;/span&gt;&lt;span class="s2"&gt;  (&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;apex &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$last&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; is not registered, anyone can buy it)"&lt;/span&gt;
    &lt;span class="k"&gt;else
      &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"DANGLING  &lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$last&lt;/span&gt;&lt;span class="s2"&gt;  (target does not resolve, check if the provider lets others claim it)"&lt;/span&gt;
    &lt;span class="k"&gt;fi
    continue
  fi

  if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$status&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"NXDOMAIN"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"GONE      &lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt; (no record at all, fine)"&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="k"&gt;fi&lt;/span&gt;

  &lt;span class="c"&gt;# -k on purpose: a dangling host often has the wrong cert, we only want the error page text&lt;/span&gt;
  &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sk&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 10 &lt;span class="s2"&gt;"https://&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 10 &lt;span class="s2"&gt;"http://&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;hit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$hit&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="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"CHECK     &lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;last&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;direct&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;  (page says: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$hit&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
  &lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"OK        &lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;last&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;direct&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To test it without pointing it at anyone else's domain, I set up a small fake zone locally. &lt;code&gt;old-promo.lab.test&lt;/code&gt; is a CNAME to the real, currently unregistered &lt;code&gt;msnmarthastewartsweeps.com&lt;/code&gt; (a replay of the MSN case), &lt;code&gt;status.lab.test&lt;/code&gt; is a CNAME to an Azure web app name that doesn't exist, and &lt;code&gt;www.lab.test&lt;/code&gt; points at the fixed MSN record. I also fed it the provider default hostnames for apps and buckets that don't exist, which is what a CNAME target looks like after you delete the app. Real output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OK        www.lab.test -&amp;gt; a-0003.a-msedge.net.
CRITICAL  old-promo.lab.test -&amp;gt; msnmarthastewartsweeps.com.  (msnmarthastewartsweeps.com is not registered, anyone can buy it)
DANGLING  status.lab.test -&amp;gt; zq8x7-none-404.azurewebsites.net.  (target does not resolve, check if the provider lets others claim it)
CHECK     zq8x7-none-404.vercel.app -&amp;gt; direct  (page says: "DEPLOYMENT_NOT_FOUND")
CHECK     zq8x7-none-404.herokuapp.com -&amp;gt; ie02.ingress.herokuapp.com.  (page says: "No such app")
CHECK     zq8x7-none-404-bucket.s3.amazonaws.com -&amp;gt; s3-w.us-east-1.amazonaws.com.  (page says: "NoSuchBucket")
CHECK     zq8x7-none-404.github.io -&amp;gt; direct  (page says: "Site not found &amp;amp;middot; GitHub Pages")
GONE      not-a-real-name.lab.test (no record at all, fine)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How to read it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CRITICAL&lt;/strong&gt;: fix today. Delete the record, or buy the domain back yourself if something still needs it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DANGLING&lt;/strong&gt;: the CNAME target is gone. For Azure, S3 and the other Vulnerable-list providers, assume someone can claim it. Delete the record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CHECK&lt;/strong&gt;: the name resolves to a provider that says "nothing here". Note Heroku and S3 resolve fine even for apps that don't exist, so NXDOMAIN alone won't catch them, that's why the script fetches the page. A CHECK doesn't prove takeover is possible. It means you have a record pointing at nothing, and you should delete it anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GONE / OK&lt;/strong&gt;: fine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also ran it on my own launch sites today: 8 domains, 31 unique hostnames from crt.sh (I didn't add zone exports for this run, so it's the cert-log view only). Nothing came back CRITICAL, DANGLING or CHECK. 18 of the 31 were GONE, old names that had a cert at some point and whose records were already deleted. That's the outcome you want, and it's also a reminder of how many names pile up without you noticing.&lt;/p&gt;

&lt;p&gt;Known gaps, so you don't trust it more than it deserves: the &lt;code&gt;apex()&lt;/code&gt; function takes the last two labels, so &lt;code&gt;something.co.uk&lt;/code&gt; will be checked as &lt;code&gt;co.uk&lt;/code&gt;. Fingerprints change when providers redesign their error pages. It doesn't look at A records pointing at raw cloud IPs you released (that's a real risk too, an IP you gave back can be handed to someone else). And a "registered" RDAP answer means somebody owns the domain, not that you do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: check your SPF includes
&lt;/h2&gt;

&lt;p&gt;Case 3 is easy to miss because it lives in a TXT record nobody reads. This one lists every &lt;code&gt;include:&lt;/code&gt;, &lt;code&gt;redirect=&lt;/code&gt;, &lt;code&gt;a:&lt;/code&gt; and &lt;code&gt;mx:&lt;/code&gt; domain and flags the ones with no registration:&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;# usage: ./spf-includes.sh example.com&lt;/span&gt;
&lt;span class="c"&gt;# lists every include:, redirect=, a: and mx: domain in the SPF record and flags ones nobody owns&lt;/span&gt;
dig +short TXT &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'v=spf1'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oiE&lt;/span&gt; &lt;span class="s1"&gt;'(include:|redirect=|\ba:|\bmx:)[^ "]+'&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'s/^(include:|redirect=|a:|mx:)//I'&lt;/span&gt; |
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; inc&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;apex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$inc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'{print $(NF-1)"."$NF}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; &lt;span class="s2"&gt;"https://rdap.org/domain/&lt;/span&gt;&lt;span class="nv"&gt;$apex&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"404"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"UNREGISTERED  &lt;/span&gt;&lt;span class="nv"&gt;$inc&lt;/span&gt;&lt;span class="s2"&gt;  &amp;lt;- anyone who buys &lt;/span&gt;&lt;span class="nv"&gt;$apex&lt;/span&gt;&lt;span class="s2"&gt; can send mail as &lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;else &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"registered    &lt;/span&gt;&lt;span class="nv"&gt;$inc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the test zone, where I put the two dead domains from Guardio's report next to a normal Google include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;registered    _spf.google.com
UNREGISTERED  directtoaccess.com  &amp;lt;- anyone who buys directtoaccess.com can send mail as lab.test
UNREGISTERED  msnmarthastewartsweeps.com  &amp;lt;- anyone who buys msnmarthastewartsweeps.com can send mail as lab.test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are unregistered again today, years after the report. Domains like this keep coming back on the market, which is why a record you cleaned up is the only real fix. And a "registered" result only tells you that somebody owns the domain, not that you do.&lt;/p&gt;

&lt;p&gt;Also look at the "registered" ones and ask if you still use that tool. Every include for a newsletter tool you cancelled is a sender you're still vouching for, and it eats into SPF's 10-lookup limit. I went through that limit in more detail when &lt;a href="https://praneetbrar.hashnode.dev/check-spf-dkim-dmarc-before-launch-email" rel="noopener noreferrer"&gt;counting SPF lookups before a launch email&lt;/a&gt;. Run the same check on subdomains you send from (&lt;code&gt;mail.&lt;/code&gt;, &lt;code&gt;news.&lt;/code&gt;), not just the root.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: fix it, in the right order
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Delete the DNS record first, then the resource.&lt;/strong&gt; Microsoft's guidance literally says to put "Remove DNS entry" on the list of required checks when decommissioning. If you delete the Heroku app first and get distracted, you've made a dangling record. Flip the order and the worst case is a broken page for a minute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify your domain with the host where it's supported.&lt;/strong&gt; GitHub Pages has &lt;a href="https://docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/verifying-your-custom-domain-for-github-pages" rel="noopener noreferrer"&gt;custom domain verification&lt;/a&gt;: you add a TXT record at &lt;code&gt;_github-pages-challenge-USERNAME.yourapp.com&lt;/code&gt; and then only your account's repos can use that domain and its immediate subdomains. GitHub's docs say takeovers can happen when you delete the repo, downgrade your plan, or anything else that unlinks the domain while DNS still points there. Azure App Service has the same idea with an &lt;code&gt;asuid.&lt;/code&gt; TXT record. Check what your host offers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No wildcard records&lt;/strong&gt; (&lt;code&gt;*.yourapp.com&lt;/code&gt;) pointing at a shared host. GitHub's docs put it bluntly: wildcards "put you at an immediate risk of domain takeovers, even if you verify the domain", because verifying &lt;code&gt;example.com&lt;/code&gt; protects &lt;code&gt;a.example.com&lt;/code&gt; but not &lt;code&gt;b.a.example.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Before you let a domain lapse, search for it.&lt;/strong&gt; Grep every zone export you have for the domain name. Any CNAME, MX, NS or SPF include that mentions it gets removed before the renewal date, not after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you find a CRITICAL that's been there a while&lt;/strong&gt;, treat it as maybe-used. Check the subdomain's current content and SPF/MX on the target, and if your session cookie was scoped to the parent domain, rotate sessions.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The 15-minute version
&lt;/h2&gt;

&lt;p&gt;If you only do part of this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Export your DNS zones and run &lt;code&gt;check-dangling.sh&lt;/code&gt; on them plus the crt.sh list.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;spf-includes.sh&lt;/code&gt; on every domain that sends mail.&lt;/li&gt;
&lt;li&gt;Look at your session cookie's Domain attribute.&lt;/li&gt;
&lt;li&gt;Add "delete the DNS record" as the first line of your project shutdown checklist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The MSN record sat there for more than two decades and the problem only started when someone bought one cheap domain. Most of us have a few more dead domains than MSN had sweepstakes.&lt;/p&gt;

&lt;p&gt;So a narrow question for people running a few products: when you kill a side project, what happens to the domain? Do you keep renewing it forever just in case, or let it go, and if you let it go, did you check that nothing still pointed at it first?&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>webdev</category>
      <category>dns</category>
    </item>
    <item>
      <title>76 of 77 trivy-action Tags Were Repointed in One Night. Some SHA Pins Didn't Help Either.</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Tue, 27 Oct 2026 05:23:11 +0000</pubDate>
      <link>https://dev.to/posting-dude/76-of-77-trivy-action-tags-were-repointed-in-one-night-some-sha-pins-didnt-help-either-46fe</link>
      <guid>https://dev.to/posting-dude/76-of-77-trivy-action-tags-were-repointed-in-one-night-some-sha-pins-didnt-help-either-46fe</guid>
      <description>&lt;p&gt;On March 19, 2026, someone with stolen credentials force-pushed 76 of the 77 version tags on &lt;code&gt;aquasecurity/trivy-action&lt;/code&gt; to point at a credential stealer. All 7 tags on &lt;code&gt;aquasecurity/setup-trivy&lt;/code&gt; too. The only clean trivy-action tag was 0.35.0, because it was an immutable release.&lt;/p&gt;

&lt;p&gt;If your workflow said &lt;code&gt;uses: aquasecurity/trivy-action@0.34.2&lt;/code&gt;, you didn't change a line. Your repo looked exactly the same. The next run just executed different code, for about 12 hours (roughly 17:43 to 05:40 UTC per the advisory), and that code read secrets out of the runner's memory and shipped them off.&lt;/p&gt;

&lt;p&gt;The part I keep coming back to is in &lt;a href="https://github.com/aquasecurity/trivy/security/advisories/GHSA-69fq-xp46-6x23" rel="noopener noreferrer"&gt;Aqua's own advisory&lt;/a&gt;, under "You are affected if you used":&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;SHA pinning to a commit prior to 2025-04-09.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So some people who did the "right" thing and pinned trivy-action to a full SHA were still exposed, if their job ran during the ~4 hours setup-trivy was poisoned. Not because the SHA moved. Because the action they pinned called &lt;em&gt;another&lt;/em&gt; action by tag.&lt;/p&gt;

&lt;p&gt;A lot of small SaaS repos have a CI file that was copied from a README in an afternoon and never opened again. Often a handful of &lt;code&gt;uses:&lt;/code&gt; lines, some on &lt;code&gt;@v3&lt;/code&gt; or &lt;code&gt;@main&lt;/code&gt;, and a deploy job with a cloud key or an npm token in it. This post is the one-hour version of fixing that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three incidents, same shape
&lt;/h2&gt;

&lt;p&gt;These are all public, with advisories you can read:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;What moved&lt;/th&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mar 14-15, 2025&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tj-actions/changed-files&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;multiple version tags repointed to one malicious commit; secrets dumped into build logs&lt;/td&gt;
&lt;td&gt;~1 day; GitHub's advisory says it impacted "over 23,000 repositories"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 3-10, 2026&lt;/td&gt;
&lt;td&gt;&lt;code&gt;xygeni/xygeni-action&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the &lt;code&gt;v5&lt;/code&gt; tag moved to a commit from an &lt;em&gt;unmerged&lt;/em&gt; pull request&lt;/td&gt;
&lt;td&gt;~6 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mar 19-20, 2026&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;aquasecurity/trivy-action&lt;/code&gt; + &lt;code&gt;setup-trivy&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;76 of 77 tags, all 7 tags&lt;/td&gt;
&lt;td&gt;~12 h and ~4 h&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In none of these did the victim's repository change. That's the whole problem with a tag. &lt;code&gt;@v5&lt;/code&gt; is not a version, it's a pointer that gets looked up every time the job starts.&lt;/p&gt;

&lt;p&gt;The xygeni one is worth a second look. Branch protection worked, the bad PRs were never merged. But the commit still existed in the repo's git objects, and moving a tag to it was enough. Merged or not doesn't matter to a tag.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways a pin fails
&lt;/h2&gt;

&lt;p&gt;People argue "tags vs SHAs" like it's one decision. It's really three separate failure modes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The tag moves.&lt;/strong&gt; &lt;code&gt;@v4&lt;/code&gt;, &lt;code&gt;@v4.2.2&lt;/code&gt;, &lt;code&gt;@main&lt;/code&gt;. All mutable unless the publisher uses immutable releases. This is tj-actions, xygeni and trivy-action.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Your pin isn't transitive.&lt;/strong&gt; You pin &lt;code&gt;owner/action@&amp;lt;sha&amp;gt;&lt;/code&gt;. That action's own &lt;code&gt;action.yml&lt;/code&gt; says &lt;code&gt;uses: owner/other-thing@v1&lt;/code&gt;. You get whatever &lt;code&gt;v1&lt;/code&gt; is today. This is the trivy-action pre-April-2025 case: until a commit on 2025-04-09 ("Pin aquasecuriy/setup-trivy to hash instead of tag"), trivy-action's &lt;code&gt;action.yaml&lt;/code&gt; called &lt;code&gt;aquasecurity/setup-trivy@v0.2.2&lt;/code&gt;. It's right there in the repo history. GitHub's own docs say it plainly now: lockfiles cover transitive dependencies, "which SHA pinning does not."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The SHA is real but it's not from where you think.&lt;/strong&gt; A full SHA can point at a commit that isn't on any branch or tag of the real repo. In the trivy binary part of the same incident, the attacker swapped an &lt;code&gt;actions/checkout&lt;/code&gt; reference to what the advisory calls an "imposter commit". GitHub's docs have a one-liner on this: when you pick a SHA, "verify it is from the action's repository and not a repository fork." It's very easy to copy a SHA from a blog post or a bot PR and never check where it lives.&lt;/p&gt;

&lt;p&gt;Here's the order I'd fix things in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: list what you actually run (10 min)
&lt;/h2&gt;

&lt;p&gt;From the repo root:&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="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rnE&lt;/span&gt; &lt;span class="s1"&gt;'^\s*-?\s*uses:\s*[^ ./$]'&lt;/span&gt; .github/workflows &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-vE&lt;/span&gt; &lt;span class="s1"&gt;'@[0-9a-f]{40}([^0-9a-f]|$)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prints every &lt;code&gt;uses:&lt;/code&gt; that points outside your repo and isn't pinned to a full 40-character SHA. It skips local &lt;code&gt;./&lt;/code&gt; actions. It will also show &lt;code&gt;docker://&lt;/code&gt; steps and reusable workflows (&lt;code&gt;jobs.&amp;lt;id&amp;gt;.uses: org/repo/.github/workflows/x.yml@main&lt;/code&gt;), which is good, you want to see those too.&lt;/p&gt;

&lt;p&gt;On a test workflow it gave me lines like:&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="na"&gt;.github/workflows/ci.yml:10:      - uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
&lt;span class="na"&gt;.github/workflows/ci.yml:12:      - uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/setup-buildx-action@v3&lt;/span&gt;
&lt;span class="na"&gt;.github/workflows/ci.yml:14:      - uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github/codeql-action/init@v3&lt;/span&gt;
&lt;span class="na"&gt;.github/workflows/ci.yml:16:        uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker://alpine:3.20&lt;/span&gt;
&lt;span class="na"&gt;.github/workflows/ci.yml:18:    uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;octo-org/shared/.github/workflows/deploy.yml@main&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same idea as &lt;a href="https://dev.to/posting-dude/lets-encrypt-goes-to-64-day-certificates-in-february-grep-your-server-for-60-80-and-83-first-lkn"&gt;grepping your server for hardcoded renewal days&lt;/a&gt; before the Let's Encrypt change. You can't fix what you haven't listed.&lt;/p&gt;

&lt;p&gt;Write the list down in three groups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub's own&lt;/strong&gt; (&lt;code&gt;actions/*&lt;/code&gt;, &lt;code&gt;github/*&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Big, well-known publishers&lt;/strong&gt; (docker, aws, google, cloudflare, vercel...)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything else.&lt;/strong&gt; Small repos, one maintainer, something you found in a Marketplace search at 1am.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 2: delete before you pin (10 min)
&lt;/h2&gt;

&lt;p&gt;The cheapest action to secure is the one you remove.&lt;/p&gt;

&lt;p&gt;A lot of third-party actions in small repos do something like "post a comment", "get changed files", "set up a tool", "send a Slack message". Some of those are 3 to 10 lines of shell plus &lt;code&gt;gh&lt;/code&gt; or &lt;code&gt;curl&lt;/code&gt;, which runs with the same token anyway but without someone else's code in the middle. For example, a lot of what people use &lt;code&gt;tj-actions/changed-files&lt;/code&gt; for is a &lt;code&gt;git diff --name-only&lt;/code&gt; between two commits.&lt;/p&gt;

&lt;p&gt;For the ones you keep, look at them once before you trust them. Open the repo: how many maintainers, how old, does it pin its &lt;em&gt;own&lt;/em&gt; dependencies, does the &lt;code&gt;action.yml&lt;/code&gt; call other actions by tag (that's failure mode 2 again). It's basically the same spelling, age and real-usage check as &lt;a href="https://sidehunt.io/studio/how-to-use-ai-coding-agents-on-a-side-project?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=github-actions-tag-pinning" rel="noopener noreferrer"&gt;checking a package an AI coding agent wants to install&lt;/a&gt;. A typo in an action name runs someone else's code with your secrets, same as a typo in &lt;code&gt;npm install&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: pin, and check the pin is honest (15 min)
&lt;/h2&gt;

&lt;p&gt;To get the commit a tag points to right now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git ls-remote https://github.com/actions/checkout &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'refs/tags/v4.2.2'&lt;/span&gt; &lt;span class="s1"&gt;'refs/tags/v4.2.2^{}'&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n1&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;^{}&lt;/code&gt; line matters. Some repos use annotated tags, and then the first SHA you see is the tag object, not the commit. &lt;code&gt;tail -n1&lt;/code&gt; takes the peeled commit when it exists. (Before you pin, check that the tag is the release you meant and not already compromised. Pinning a bad commit just makes it permanent.)&lt;/p&gt;

&lt;p&gt;Then write it with the version as a comment so humans can read it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683&lt;/span&gt; &lt;span class="c1"&gt;# v4.2.2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the check most people skip. This script reads every &lt;code&gt;@&amp;lt;sha&amp;gt; # &amp;lt;tag&amp;gt;&lt;/code&gt; line and asks GitHub what that tag points to today:&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;# For every "uses: owner/repo@&amp;lt;full sha&amp;gt; # &amp;lt;tag&amp;gt;" line, ask GitHub&lt;/span&gt;
&lt;span class="c"&gt;# what that tag points to right now and compare it with the pin.&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rhoE&lt;/span&gt; &lt;span class="s1"&gt;'uses:\s*[^ ./$][^ @]*@[0-9a-f]{40}\s*#\s*\S+'&lt;/span&gt; .github/workflows |
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; _ ref _ tag&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;sha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;##*@&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
  &lt;span class="nv"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;%@*&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;/ &lt;span class="nt"&gt;-f1-2&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;now&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git ls-remote &lt;span class="s2"&gt;"https://github.com/&lt;/span&gt;&lt;span class="nv"&gt;$repo&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="s2"&gt;"refs/tags/&lt;/span&gt;&lt;span class="nv"&gt;$tag&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"refs/tags/&lt;/span&gt;&lt;span class="nv"&gt;$tag&lt;/span&gt;&lt;span class="s2"&gt;^{}"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n1&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$now&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$sha&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="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ok     &lt;/span&gt;&lt;span class="nv"&gt;$repo&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$tag&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"CHECK  &lt;/span&gt;&lt;span class="nv"&gt;$repo&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$tag&lt;/span&gt;&lt;span class="s2"&gt; pinned=&lt;/span&gt;&lt;span class="nv"&gt;$sha&lt;/span&gt;&lt;span class="s2"&gt; tag-now=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;now&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;missing&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On my test file, with one deliberately wrong pin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ok     actions/checkout v4.2.2
CHECK  actions/checkout v4.2.2 pinned=d632683dd7b4114ad314bca15554477dd762a938 tag-now=11bd71901bbe5b1630ceea73d27597364c9af683
ok     pnpm/action-setup v3.0.0
ok     github/codeql-action v3.29.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;CHECK&lt;/code&gt; means one of two things, and both are worth five minutes: either your SHA was never that tag (copied wrong, or an imposter commit), or the tag moved after you pinned it. The second one is exactly the event you want to hear about. Your pin protected you, but it's also a signal that upstream had a bad day and you should go read their issues.&lt;/p&gt;

&lt;p&gt;It only checks the format &lt;code&gt;@sha # tag&lt;/code&gt;, it handles &lt;code&gt;owner/repo/subdir&lt;/code&gt; actions like &lt;code&gt;github/codeql-action/init&lt;/code&gt;, and it needs nothing but &lt;code&gt;git&lt;/code&gt; and &lt;code&gt;grep&lt;/code&gt;. You could run it weekly in a scheduled job. It does not look inside the actions for failure mode 2, that's what the next step is for.&lt;/p&gt;

&lt;p&gt;One tradeoff that's easy to miss: GitHub's docs say Dependabot "will not create alerts for actions pinned to SHA values", alerts are only for actions using semantic versioning. So if you pin by SHA, turn on Dependabot &lt;em&gt;version updates&lt;/em&gt; for &lt;code&gt;github-actions&lt;/code&gt; so the pins don't rot. Since there's a default cooldown now (Dependabot waits 3 days after a release before proposing it, on github.com), a freshly poisoned release has some time to get caught before a bot PR shows up. That helps with bad new releases. It does nothing for a tag that gets repointed under you, only the pin does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: or try the new lockfile (15 min)
&lt;/h2&gt;

&lt;p&gt;On October 14, 2026, GitHub put &lt;a href="https://docs.github.com/en/actions/concepts/security/workflow-dependency-lockfiles" rel="noopener noreferrer"&gt;workflow dependency lockfiles&lt;/a&gt; into public preview, including on the Free plan. It's basically &lt;code&gt;package-lock.json&lt;/code&gt; for Actions. A CLI extension writes &lt;code&gt;.github/workflows/actions.lock&lt;/code&gt; with the commit for every action your workflows use, including the transitive ones, plus the numeric owner and repo IDs. Once a workflow is onboarded, Actions runs the locked commit even if the tag moves, and every dependency of that workflow has to be in the lockfile.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh extension &lt;span class="nb"&gt;install &lt;/span&gt;github/gh-actions-lock
gh actions-lock                 &lt;span class="c"&gt;# scan .github/workflows, write actions.lock&lt;/span&gt;
gh actions-lock &lt;span class="nt"&gt;--verify&lt;/span&gt;        &lt;span class="c"&gt;# read-only check, good for a CI job&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your workflow keeps readable refs (it may narrow &lt;code&gt;@v4&lt;/code&gt; to &lt;code&gt;@v4.2.2&lt;/code&gt;). GitHub's docs now say that for repos with a lockfile, SHA pinning in the workflow file is "no longer necessary". There's also a "Require lockfile" policy at repo/org level, and Dependabot regenerates the lockfile entry in the same PR when it bumps an onboarded workflow.&lt;/p&gt;

&lt;p&gt;Things to know before you rely on it, straight from the README and release notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It's pre-1.0 and in preview. v0.3.0 (Oct 22) changed behavior enough that some lockfiles that used to pass &lt;code&gt;--verify&lt;/code&gt; now fail it.&lt;/li&gt;
&lt;li&gt;Reusable workflows called from another repo (&lt;code&gt;jobs.&amp;lt;id&amp;gt;.uses&lt;/code&gt;) are not locked by your lockfile.&lt;/li&gt;
&lt;li&gt;Container images are not locked. Anything without an &lt;code&gt;@sha256:&lt;/code&gt; digest is only a warning.&lt;/li&gt;
&lt;li&gt;A lockfile change that moves a floating ref like &lt;code&gt;v4&lt;/code&gt; &lt;em&gt;back&lt;/em&gt; to an older commit in its history isn't detected. So review lockfile diffs, or pin exact tags.&lt;/li&gt;
&lt;li&gt;Since v0.3.0 it rejects full-SHA pins to fork-only (imposter) commits, every locked commit must be in the history of its recorded tag or branch. That covers failure mode 3.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have one repo and a few workflows, I'd honestly try this over hand-pinning. If you're not ready for a preview tool, steps 1 to 3 still get you most of the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: make a stolen run worth less (10 min)
&lt;/h2&gt;

&lt;p&gt;Pinning is about which code runs. This step is about what that code can grab if something slips through anyway. The tj-actions and trivy payloads both went for secrets in the runner's memory. The xygeni one gave the attacker a remote shell on the runner for up to 180 seconds per run, which reaches the same things.&lt;/p&gt;

&lt;p&gt;At the top of every workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then give extra scopes only to the job that needs them:&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="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;comment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
    &lt;span class="c1"&gt;# steps: ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you set any permission, every one you didn't list becomes &lt;code&gt;none&lt;/code&gt;. &lt;code&gt;permissions: {}&lt;/code&gt; turns them all off.&lt;/p&gt;

&lt;p&gt;Why bother if new repos default to read-only? Because that default only applies to things created after February 2, 2023. GitHub's changelog was explicit: the change "will not impact any existing enterprises, organizations or repositories." If your repo or org is older than that, go to Settings → Actions → General → Workflow permissions and look. An older repo can still say "Read and write" there.&lt;/p&gt;

&lt;p&gt;Two more:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep prod secrets out of the PR workflow.&lt;/strong&gt; Your test job doesn't need the production database URL or the live payment key. Put deploy secrets in an environment with required reviewers. Then only jobs that use that environment can read them, and only after someone clicks approve.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer short-lived cloud creds.&lt;/strong&gt; If you deploy to AWS/GCP/Azure, OIDC gives the job a token that expires, instead of a long-lived key sitting in repo secrets forever. On GitHub-hosted runners the &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; itself already dies with the job, max 6 hours.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 6: if you ran a bad version
&lt;/h2&gt;

&lt;p&gt;All three advisories say some version of the same thing: treat every secret the pipeline could read as exposed, and rotate it. Not "probably fine". The trivy payload read the runner's memory, so masked secrets in the logs didn't matter.&lt;/p&gt;

&lt;p&gt;The order I'd go in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Find the runs inside the advisory's window. &lt;code&gt;gh run list --created 2026-03-19..2026-03-20&lt;/code&gt; works, or just scroll the Actions tab.&lt;/li&gt;
&lt;li&gt;Rotate whatever those jobs could reach. Cloud keys, deploy keys, package registry tokens, any API key in repo or org secrets.&lt;/li&gt;
&lt;li&gt;For trivy specifically, look for a repo named &lt;code&gt;tpcp-docs&lt;/code&gt; in your account or org. Per the advisory, the stealer creates it as a fallback upload spot when its normal exfiltration fails.&lt;/li&gt;
&lt;li&gt;Check what got published from those runs (releases, packages, images) in case something was tampered.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rotation is the slow part if you don't know where each key is used. Writing that list down &lt;em&gt;before&lt;/em&gt; you need it is worth doing just for this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules I'd actually adopt
&lt;/h2&gt;

&lt;p&gt;At under 50 customers, with one or two people touching CI:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Third-party actions: always locked (lockfile) or SHA-pinned, with the version comment. No exceptions for "it's popular".&lt;/li&gt;
&lt;li&gt;GitHub's own &lt;code&gt;actions/*&lt;/code&gt;: I'd pin them too. GitHub's "Require actions to be pinned to a full-length commit SHA" policy (added August 2025) treats them the same, and it makes the rule simple enough to enforce.&lt;/li&gt;
&lt;li&gt;Fewer actions every quarter, not more.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;permissions: contents: read&lt;/code&gt; at the top of every workflow, widen per job.&lt;/li&gt;
&lt;li&gt;The pin check, weekly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total time is about an hour for a typical small repo. The trivy-action window was about twelve hours, starting on a Thursday evening UTC, and the real scan still ran after the stealer, so the job looked normal. Nobody watching their CI casually would have caught it.&lt;/p&gt;

&lt;p&gt;I'm curious how other small teams handle this. Are you pinning SHAs by hand, trying the new &lt;code&gt;gh actions-lock&lt;/code&gt; preview, or still on &lt;code&gt;@v4&lt;/code&gt;? And if you already pin by SHA, have you ever opened the &lt;code&gt;action.yml&lt;/code&gt; of what you pinned to see if it calls other actions by tag?&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>github</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Let's Encrypt Goes to 64-Day Certificates in February. Grep Your Server for 60, 80 and 83 First.</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Mon, 26 Oct 2026 05:18:30 +0000</pubDate>
      <link>https://dev.to/posting-dude/lets-encrypt-goes-to-64-day-certificates-in-february-grep-your-server-for-60-80-and-83-first-lkn</link>
      <guid>https://dev.to/posting-dude/lets-encrypt-goes-to-64-day-certificates-in-february-grep-your-server-for-60-80-and-83-first-lkn</guid>
      <description>&lt;p&gt;Let's Encrypt posted &lt;a href="https://letsencrypt.org/2026/10/07/64-day-certs" rel="noopener noreferrer"&gt;a short note on October 7&lt;/a&gt; that most small SaaS founders will scroll past. Starting February 10, 2027, every certificate it issues or renews will be valid for 64 days by default, not 90. Then on February 16, 2028, it drops again to 45 days.&lt;/p&gt;

&lt;p&gt;The post also has one line I'd tape to the monitor:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Grep for common hardcoded numbers like 83, 80 or 60 in cron jobs, wrapper scripts and runbooks if you're not sure.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the whole risk in one sentence. If your HTTPS renewal was set up two years ago by "past you" and has worked silently since, there's a decent chance a 60 is hiding somewhere in it. On 90-day certs that 60 left you 30 days of slack. On 64-day certs it leaves 4. On 45-day certs it renews two weeks after the cert already died.&lt;/p&gt;

&lt;p&gt;This post is the boring checklist I'd run on a tiny SaaS before February. Most of it takes an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dates, in one place
&lt;/h2&gt;

&lt;p&gt;There are two separate things changing and people mix them up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The industry rule.&lt;/strong&gt; The CA/Browser Forum (the group whose rules every public CA follows) passed ballot SC-081 in 2025. Max certificate lifetime for certs issued:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;from March 15, 2026: 200 days (it was 398)&lt;/li&gt;
&lt;li&gt;from March 15, 2027: 100 days&lt;/li&gt;
&lt;li&gt;from March 15, 2029: 47 days&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Let's Encrypt's own schedule&lt;/strong&gt;, which runs ahead of that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;now: 90 days by default&lt;/li&gt;
&lt;li&gt;opt-in already: the &lt;code&gt;shortlived&lt;/code&gt; profile issues ~6-day certs, and since May 13, 2026 the &lt;code&gt;tlsserver&lt;/code&gt; profile issues 45-day ones&lt;/li&gt;
&lt;li&gt;since October 14, 2026: the staging environment already issues 64-day certs, for testing&lt;/li&gt;
&lt;li&gt;February 10, 2027: default goes to 64 days&lt;/li&gt;
&lt;li&gt;around May 11, 2027: the last 90-day Let's Encrypt cert expires&lt;/li&gt;
&lt;li&gt;February 16, 2028: default goes to 45 days&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing gets revoked. You just get the shorter cert at your next renewal after each date.&lt;/p&gt;

&lt;p&gt;If you buy certs instead (from your registrar, or a "1 year SSL" upsell), you're already on the 200-day rule. A public example: as of late October 2026, dev.to itself serves a certificate issued in November 2025 that runs until December 12, 2026. That's 397 days, issued before the cutoff. Its replacement can't be longer than 200 days, and the one after March 2027 can't be longer than 100. If someone on your team installs a bought cert by hand once a year, that turns into at least twice a year now, about four times a year from 2027, and eight or more a year from 2029. Manual stops being a plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a tiny SaaS should care more than a big one
&lt;/h2&gt;

&lt;p&gt;Big companies have a platform team and a cert inventory. At 30 customers, the cert inventory is "whatever certbot did in 2024."&lt;/p&gt;

&lt;p&gt;Two things make it worse now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Nobody emails you anymore.&lt;/strong&gt; Let's Encrypt stopped sending expiry reminder emails on June 4, 2025. If you set things up when those emails existed, your safety net was quietly removed. Certbot's own docs still have a line saying the CA will email you, which doesn't help.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhooks fail before customers notice.&lt;/strong&gt; Stripe needs a valid certificate on your webhook endpoint in live mode. If the cert expires, deliveries show a TLS error and Stripe retries for up to three days. After that you're replaying events by hand, and in the meantime renewals, cancellations and failed payments aren't reaching your app. Your marketing site might be on a host that handles certs for you while &lt;code&gt;api.yourapp.com&lt;/code&gt; sits on a VPS that doesn't.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 1: find out what actually renews your certs
&lt;/h2&gt;

&lt;p&gt;Before grepping anything, figure out which tool is in charge. On a typical small setup it's one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A managed host or proxy&lt;/strong&gt; (Vercel, Netlify, Cloudflare, Render, Fly, a managed load balancer). They issue and renew for you. Mostly not your problem, but check any custom domains you added by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caddy.&lt;/strong&gt; It handles renewal itself and has supported ACME Renewal Information (ARI) since 2.8, so it asks Let's Encrypt when to renew.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Certbot.&lt;/strong&gt; Usually a systemd timer or a cron entry someone added.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;acme.sh.&lt;/strong&gt; A cron entry under your user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Something homemade.&lt;/strong&gt; A script, a Docker container that runs certbot once, a GitHub Action. These are the ones that bite.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On a Linux box, this finds most of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl list-timers &lt;span class="nt"&gt;--all&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'certbot|acme'&lt;/span&gt;
&lt;span class="nb"&gt;sudo grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'certbot|acme|renew'&lt;/span&gt; /etc/crontab /etc/cron.d /var/spool/cron 2&amp;gt;/dev/null
crontab &lt;span class="nt"&gt;-l&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'certbot|acme|renew'&lt;/span&gt;
certbot &lt;span class="nt"&gt;--version&lt;/span&gt; 2&amp;gt;/dev/null
~/.acme.sh/acme.sh &lt;span class="nt"&gt;--version&lt;/span&gt; 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write down what you find. Seriously, one line in a notes file: "api box, certbot 2.x, systemd timer, nginx reload hook: no". You'll need it in step 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: grep for the hardcoded numbers
&lt;/h2&gt;

&lt;p&gt;Here's what common renewal rules do on each lifetime. "Slack" is how many days are left on the old cert when renewal first tries.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Renewal rule&lt;/th&gt;
&lt;th&gt;90-day cert&lt;/th&gt;
&lt;th&gt;64-day cert&lt;/th&gt;
&lt;th&gt;45-day cert&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ask the CA via ARI (about 2/3 of lifetime)&lt;/td&gt;
&lt;td&gt;~day 60, ~30 left&lt;/td&gt;
&lt;td&gt;~day 43, ~21 left&lt;/td&gt;
&lt;td&gt;~day 30, ~15 left&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Certbot 4.0+ default (1/3 lifetime left)&lt;/td&gt;
&lt;td&gt;day 60, 30 left&lt;/td&gt;
&lt;td&gt;~day 43, ~21 left&lt;/td&gt;
&lt;td&gt;day 30, 15 left&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Old certbot default (30 days left)&lt;/td&gt;
&lt;td&gt;day 60, 30 left&lt;/td&gt;
&lt;td&gt;day 34, 30 left&lt;/td&gt;
&lt;td&gt;day 15, 30 left&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;acme.sh 3.1.2 and older default (59 days after issue)&lt;/td&gt;
&lt;td&gt;31 left&lt;/td&gt;
&lt;td&gt;5 left&lt;/td&gt;
&lt;td&gt;expired 14 days earlier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hand cron, &lt;code&gt;--force-renewal&lt;/code&gt; every 60 days&lt;/td&gt;
&lt;td&gt;30 left&lt;/td&gt;
&lt;td&gt;4 left&lt;/td&gt;
&lt;td&gt;expired 15 days earlier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runbook "renew at 80 / 83 days"&lt;/td&gt;
&lt;td&gt;10 / 7 left&lt;/td&gt;
&lt;td&gt;expired&lt;/td&gt;
&lt;td&gt;expired&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The "days left" rules are fine. The "days since issue" rules are the ones that break. That's what Let's Encrypt means by grepping for 60, 80 and 83.&lt;/p&gt;

&lt;p&gt;Where to look:&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;# certbot: per-cert overrides (no output is good)&lt;/span&gt;
&lt;span class="nb"&gt;sudo grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; renew_before_expiry /etc/letsencrypt/renewal/

&lt;span class="c"&gt;# acme.sh: older versions wrote Le_RenewalDays='60' into every cert's conf&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; Le_RenewalDays ~/.acme.sh/
&lt;span class="c"&gt;# (if you never passed --days, a 60 here is just the old default)&lt;/span&gt;

&lt;span class="c"&gt;# anything homemade that forces renewal on a fixed schedule&lt;/span&gt;
&lt;span class="nb"&gt;sudo grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'force-renew|--days|renew.*(60|80|83)'&lt;/span&gt; /etc/cron&lt;span class="k"&gt;*&lt;/span&gt; /var/spool/cron /etc/systemd/system ~/bin ~/scripts 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things from the changelogs worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Certbot 4.0.0 switched its default from a fixed 30 days to "renew when less than 1/3 of the lifetime is left". Certbot 4.1.0 added ARI support, and an early ARI answer wins over a later &lt;code&gt;renew_before_expiry&lt;/code&gt;. So a recent certbot with the stock timer is basically fine. An old certbot pinned by your distro is the thing to check.&lt;/li&gt;
&lt;li&gt;acme.sh changed its default from 60 days to 30 days and added ARI in 3.1.3 (April 2026). Before that, the default renewal was 60 days after issue, minus a day. If you installed it a while ago and turned off auto-upgrade, &lt;code&gt;acme.sh --upgrade&lt;/code&gt; is step one. Then run &lt;code&gt;acme.sh --info -d yourdomain.com&lt;/code&gt; and look at &lt;code&gt;Le_NextRenewTimeStr&lt;/code&gt; to see when it actually plans to renew, instead of trusting what you remember.&lt;/li&gt;
&lt;li&gt;If your "automation" is a cron line with &lt;code&gt;--force-renewal&lt;/code&gt; on a fixed calendar, replace it with the plain &lt;code&gt;certbot renew&lt;/code&gt; running twice a day. Certbot's docs recommend exactly that and say it's safe to run as often as you like, since it does nothing until a cert is due.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: renewal isn't deployment
&lt;/h2&gt;

&lt;p&gt;This one gets people even with perfect timing. Certbot renews the file on disk. Nginx keeps serving the old cert from memory until it reloads. If nothing reloads it, the new cert sits there unused and the old one expires right on schedule.&lt;/p&gt;

&lt;p&gt;With certbot, add a deploy hook. It only runs after a successful renewal:&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="nb"&gt;sudo tee&lt;/span&gt; /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;HOOK&lt;/span&gt;&lt;span class="sh"&gt;'
#!/bin/sh
nginx -t &amp;amp;&amp;amp; systemctl reload nginx
&lt;/span&gt;&lt;span class="no"&gt;HOOK
&lt;/span&gt;&lt;span class="nb"&gt;sudo chmod&lt;/span&gt; +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With acme.sh it's the &lt;code&gt;--reloadcmd&lt;/code&gt; you pass when installing the cert. Their README flags it as IMPORTANT, and they're right.&lt;/p&gt;

&lt;p&gt;Other places the new cert never reaches: a Docker image that copied the cert in at build time, a load balancer or CDN where you uploaded a cert by hand, a second server that gets the cert by rsync from the first one. On 90-day certs you might've caught that by accident. On 64-day certs, you get fewer accidents.&lt;/p&gt;

&lt;p&gt;To test the renew-plus-hook path without touching real certs:&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="nb"&gt;sudo &lt;/span&gt;certbot renew &lt;span class="nt"&gt;--dry-run&lt;/span&gt; &lt;span class="nt"&gt;--run-deploy-hooks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--dry-run&lt;/code&gt; talks to the staging server and doesn't save anything. Deploy hooks don't run during a dry run unless you add &lt;code&gt;--run-deploy-hooks&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: check the certificate people actually get, from outside
&lt;/h2&gt;

&lt;p&gt;Checking the file on disk tells you what certbot did. It doesn't tell you what browsers and Stripe see. I'd run something like this from a machine that isn't the server, once a day:&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;# check-certs.sh: fail if any cert you actually serve expires within DAYS days&lt;/span&gt;
&lt;span class="nv"&gt;DAYS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;14
&lt;span class="nv"&gt;HOSTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"example.com www.example.com api.example.com status.example.com"&lt;/span&gt;

&lt;span class="nv"&gt;fail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="k"&gt;for &lt;/span&gt;h &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$HOSTS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;cert&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; | &lt;span class="nb"&gt;timeout &lt;/span&gt;10 openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="s2"&gt;:443"&lt;/span&gt; &lt;span class="nt"&gt;-servername&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;end&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cert&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-enddate&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;-f2&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$end&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="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL &lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="s2"&gt;: couldn't get a certificate"&lt;/span&gt;
    &lt;span class="nv"&gt;fail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
  &lt;span class="k"&gt;elif&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;"&lt;/span&gt;&lt;span class="nv"&gt;$cert&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | openssl x509 &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-checkend&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt;DAYS &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;86400&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null&lt;span class="p"&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;"FAIL &lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="s2"&gt;: expires &lt;/span&gt;&lt;span class="nv"&gt;$end&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nv"&gt;fail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
  &lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ok   &lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="s2"&gt;: expires &lt;/span&gt;&lt;span class="nv"&gt;$end&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;fi
done
&lt;/span&gt;&lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="nv"&gt;$fail&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-checkend&lt;/code&gt; takes seconds and exits 1 if the cert expires inside that window. &lt;code&gt;-servername&lt;/code&gt; matters if more than one site lives on the same IP, otherwise you might be checking the wrong cert.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick the alert threshold for the new lifetimes.&lt;/strong&gt; A lot of us have a "warn at 30 days left" rule from the 90-day era. On a 64-day cert renewed at about 2/3, a healthy cert hits 30 days left around day 34, roughly 9 days before renewal even tries. So the alert fires on every healthy cert, you mute it, and then it can't help you when it matters. I'd use 14 days for 64-day certs and something like 7 to 10 once you're on 45-day ones.&lt;/p&gt;

&lt;p&gt;Where the alert goes matters as much as the check. The SideHunt guide on &lt;a href="https://sidehunt.io/studio/how-to-monitor-a-side-project-during-your-day-job?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=letsencrypt-64-day-certs" rel="noopener noreferrer"&gt;monitoring a side project when you can't watch it all day&lt;/a&gt; splits alerts into "interrupt me now" and "tonight is fine". A cert with 14 days left is a tonight one. A cert with 2 days left, or a FAIL on the webhook host, is a now one.&lt;/p&gt;

&lt;p&gt;And the check script is itself a cron job that can die quietly. If it stops running, "no alerts" looks exactly like "everything's fine". I wrote about &lt;a href="https://dev.to/posting-dude/your-uptime-monitor-cant-see-a-dead-cron-job-heartbeat-the-5-jobs-a-tiny-saas-runs-2ajg"&gt;heartbeat checks for the jobs a tiny SaaS runs&lt;/a&gt;. Same idea here: ping on success so you hear about it when the check itself stops.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: find the certs you forgot about
&lt;/h2&gt;

&lt;p&gt;The hostnames list in that script is only as good as your memory. Certificate Transparency logs remember better. Every public cert gets logged, so you can see every name anyone got a cert for on your domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://crt.sh/?q=example.com&amp;amp;output=json"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[].name_value'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;crt.sh is free and kind of slow, and sometimes it just returns a 502. Retry in a minute. What usually turns up: an old &lt;code&gt;staging.&lt;/code&gt; subdomain still pointing at a box, &lt;code&gt;status.&lt;/code&gt; on some service you set up once, a &lt;code&gt;mail.&lt;/code&gt; cert on the mail server, a customer's custom domain. Anything still live goes into &lt;code&gt;HOSTS&lt;/code&gt;. Anything dead, delete the DNS record so it stops being a thing.&lt;/p&gt;

&lt;p&gt;While you're thinking about expiry dates, the domain itself expires too, and a cert check won't catch that one. The IndieHunt guide on &lt;a href="https://indiehunt.io/studio/how-to-protect-your-indie-saas-from-account-lockouts?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=letsencrypt-64-day-certs" rel="noopener noreferrer"&gt;protecting a small SaaS from account lockouts&lt;/a&gt; covers registrar lock, auto-renew and the "renewal emails go to the domain that expired" trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: if anything still renews by hand, fix that now
&lt;/h2&gt;

&lt;p&gt;Two cases I'd look for:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual DNS challenges.&lt;/strong&gt; If you ran &lt;code&gt;certbot certonly --manual&lt;/code&gt; with DNS and paste a TXT record each time, certbot's docs are clear that those certs won't renew automatically unless you add auth hook scripts. Today Let's Encrypt reuses a successful validation for 30 days. From February 2027 that's 10 days, and on the 45-day schedule in 2028 it's 7 hours. So basically every renewal will need a fresh challenge. Move to a DNS plugin for your DNS provider, or to HTTP validation if the box is reachable on port 80.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bought certs.&lt;/strong&gt; If you pay a registrar for a cert because "that's how we did it in 2019", see whether your host can do ACME instead. A lot of paid CAs support ACME too, if you need a paid one for some reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: try the short certs early, on something that doesn't matter
&lt;/h2&gt;

&lt;p&gt;You don't have to wait for February to find out what breaks. Two options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Staging is already on 64-day certs. Point a throwaway subdomain at a test box, issue with &lt;code&gt;--staging&lt;/code&gt;, and watch it over a couple of weeks.&lt;/li&gt;
&lt;li&gt;Or opt one low-stakes production name into 45-day certs now. In recent certbot that's &lt;code&gt;--preferred-profile tlsserver&lt;/code&gt;, which falls back to the default profile if the profile ever disappears. &lt;code&gt;--required-profile&lt;/code&gt; fails instead, which you probably don't want on a real site.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your setup survives 45-day certs, February is a non-event.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-hour version
&lt;/h2&gt;

&lt;p&gt;If you only do part of this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Find what renews each cert, and write it down.&lt;/li&gt;
&lt;li&gt;Grep for 60, 80, 83, &lt;code&gt;--force-renewal&lt;/code&gt; and &lt;code&gt;renew_before_expiry&lt;/code&gt;. Upgrade old certbot and acme.sh.&lt;/li&gt;
&lt;li&gt;Make sure a successful renewal reloads whatever serves the cert.&lt;/li&gt;
&lt;li&gt;Add the outside check on every hostname, including the webhook host, alerting at 14 days, with a heartbeat on the check.&lt;/li&gt;
&lt;li&gt;Run crt.sh once to find hostnames you forgot.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most of the time nothing is wrong. But "the cert expired" outages are almost always on a box someone set up once and stopped thinking about, and February takes away a lot of the slack those boxes have been living on.&lt;/p&gt;

&lt;p&gt;What renews the certs on your server right now, and do you actually know what day it fires? And if anyone has already moved a real site to the 45-day &lt;code&gt;tlsserver&lt;/code&gt; profile, did anything break?&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>webdev</category>
      <category>saas</category>
    </item>
    <item>
      <title>When a Customer Asks to Pay by Invoice Instead of a Card and You Have Under 50 Customers</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Sun, 25 Oct 2026 05:14:06 +0000</pubDate>
      <link>https://dev.to/posting-dude/when-a-customer-asks-to-pay-by-invoice-instead-of-a-card-and-you-have-under-50-customers-53a1</link>
      <guid>https://dev.to/posting-dude/when-a-customer-asks-to-pay-by-invoice-instead-of-a-card-and-you-have-under-50-customers-53a1</guid>
      <description>&lt;p&gt;Say you sell a $19 per seat tool and most of your 40-ish customers pay monthly by card through Checkout. Then an office manager at a 12-person agency writes:&lt;/p&gt;

&lt;p&gt;"We'd like to move the whole team to the annual plan, but our finance team can't put this on a card. Can you send us an invoice, net 30, and we'll pay by bank transfer?"&lt;/p&gt;

&lt;p&gt;That's 12 seats x $19 x 12 months, so $2,736 in one go. At under 50 customers that might be your biggest single payment of the year. You want to say yes. But your billing setup assumes a card on file, and "send an invoice and wait" quietly breaks a couple of assumptions in your app, mainly who gets access and when.&lt;/p&gt;

&lt;p&gt;This is the version I'd want before replying to that email. What changes in Stripe when a customer pays by invoice, the access trap that comes with it, how bank transfers actually land, and a short policy so the next one takes five minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "pay by invoice" means in Stripe
&lt;/h2&gt;

&lt;p&gt;Every Stripe subscription has a &lt;code&gt;collection_method&lt;/code&gt;. There are only two values:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;charge_automatically&lt;/code&gt;: the default. Stripe charges the default payment method every period. This is what Checkout subscriptions use.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;send_invoice&lt;/code&gt;: Stripe creates the invoice and emails it to the customer, with a due date. They pay it themselves, whenever they get to it before (hopefully) the due date.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For &lt;code&gt;send_invoice&lt;/code&gt; you also set &lt;code&gt;days_until_due&lt;/code&gt; (the "net 30" part). The invoice email links to a hosted invoice page where the customer can pay with whatever payment methods you've enabled for invoices, and download a PDF of the invoice and the receipt. For finance teams the PDF is the whole point, they need something to attach in their accounting system.&lt;/p&gt;

&lt;p&gt;One small timing detail from the subscription invoice docs: with &lt;code&gt;charge_automatically&lt;/code&gt; Stripe finalizes the renewal invoice right away, with &lt;code&gt;send_invoice&lt;/code&gt; it finalizes it about an hour later. So if you have code that reacts to &lt;code&gt;invoice.finalized&lt;/code&gt;, don't expect it at the exact second the period renews.&lt;/p&gt;

&lt;p&gt;If this agency also wants their VAT number or company address on the invoice, that's its own small project. I wrote up &lt;a href="https://dev.to/posting-dude/when-a-customer-asks-for-an-invoice-with-their-vat-number-and-you-have-under-50-customers-f5h"&gt;what to set before you send a business customer an invoice with their VAT number&lt;/a&gt;, and it's worth doing that first, because a finalized invoice keeps the tax details it was finalized with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: "active" doesn't mean "paid" anymore
&lt;/h2&gt;

&lt;p&gt;This is the part that matters most, and it's easy to miss.&lt;/p&gt;

&lt;p&gt;With card subscriptions, a new subscription whose first payment fails sits in &lt;code&gt;incomplete&lt;/code&gt;. Your paywall checks for &lt;code&gt;active&lt;/code&gt; (or &lt;code&gt;active&lt;/code&gt; and &lt;code&gt;trialing&lt;/code&gt;) and everything is fine, because active roughly means "money came in".&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;send_invoice&lt;/code&gt;, Stripe's API reference says it plainly: subscriptions with &lt;code&gt;collection_method=send_invoice&lt;/code&gt; "are automatically activated regardless of the first Invoice status." The subscriptions overview says the same thing, the initial status is &lt;code&gt;active&lt;/code&gt; "even if the first invoice is unpaid."&lt;/p&gt;

&lt;p&gt;So if your app does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasAccess&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;trialing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then the agency gets full access for 12 seats the minute you create the subscription, before a cent arrives. For net 30, that's 30 days of access on trust, plus however long it takes you to notice the invoice is overdue.&lt;/p&gt;

&lt;p&gt;Later on, the status does catch up. When a &lt;code&gt;send_invoice&lt;/code&gt; invoice isn't paid by its due date, the subscription moves to &lt;code&gt;past_due&lt;/code&gt;. Then, depending on your settings, it can stay &lt;code&gt;past_due&lt;/code&gt;, become &lt;code&gt;unpaid&lt;/code&gt; or get &lt;code&gt;canceled&lt;/code&gt;. More on that below.&lt;/p&gt;

&lt;p&gt;For a 12-person agency that asked politely, access on trust is probably fine. The point is to decide it on purpose instead of finding out your paywall decided for you. Three options:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Policy&lt;/th&gt;
&lt;th&gt;How it works&lt;/th&gt;
&lt;th&gt;Good for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Access on trust&lt;/td&gt;
&lt;td&gt;Give access when the subscription is created, cut it when it goes &lt;code&gt;past_due&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Known companies, renewals of existing customers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Access on payment&lt;/td&gt;
&lt;td&gt;Keep the account locked (or on a trial) until the first invoice is &lt;code&gt;paid&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;New companies you've never heard of&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Short first term&lt;/td&gt;
&lt;td&gt;First invoice net 7, later renewals net 30&lt;/td&gt;
&lt;td&gt;Somewhere in between&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Update (Oct 26):&lt;/strong&gt; thanks to &lt;a class="mentioned-user" href="https://dev.to/davekurian"&gt;@davekurian&lt;/a&gt; for catching a bug in the first version of this snippet: it checked &lt;code&gt;latest_invoice&lt;/code&gt;, which on a renewal is the new invoice still open inside net 30, so it locked out customers who'd already paid for the year. The version below stores a paid-through date from &lt;code&gt;invoice.paid&lt;/code&gt; and checks it plus a grace window.&lt;/p&gt;

&lt;p&gt;If you go with "access on payment", don't check the latest invoice at access time. Store a paid-through date when Stripe tells you an invoice was paid, and check against that. Something like this (illustrative, check the field names against your SDK and API version):&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;// Webhook handler, after verifying the signature.&lt;/span&gt;
&lt;span class="c1"&gt;// invoice.paid also fires when you mark an invoice paid out of band.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice.paid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Invoice&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// API 2025-03-31 and later; older versions use inv.subscription&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;subscription_details&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;subscription&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;subId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;subId&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Stripe's docs: confirm the subscription is active before extending access&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// the service period this invoice paid for (not inv.period_end)&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ends&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;subscription_item_details&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;period&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ends&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;savePaidThrough&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;ends&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// only ever move it forward&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hasAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Subscription&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;collection_method&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;send_invoice&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;trialing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;canceled&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unpaid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;incomplete_expired&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;paused&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="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="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;hasManualOverride&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;paidThrough&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;getPaidThrough&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// unix seconds, null until an invoice is paid&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;paidThrough&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;// first invoice not paid yet: stays strict&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;graceDays&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;days_until_due&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// net terms plus a week&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;paidThrough&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;graceDays&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400&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 first invoice stays strict (no paid-through date, no access). A renewal keeps access while the new invoice is open, up to its net terms plus a week. Use the line item's &lt;code&gt;period.end&lt;/code&gt; for this, not the invoice's &lt;code&gt;period_end&lt;/code&gt;. Stripe's docs point you to the line item period for the service period, and on a renewal invoice &lt;code&gt;period_end&lt;/code&gt; is not the end of the year they just paid for. If you have customers who paid before you add this, backfill their paid-through date once from their last paid invoice.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;hasManualOverride&lt;/code&gt; bit is for the "they emailed me the remittance PDF and the bank transfer is on its way" case. You'll want a way to say "yes, give them access now" without hacking the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  How bank transfers actually land
&lt;/h2&gt;

&lt;p&gt;You can let invoice customers pay by card on the hosted invoice page too, but if finance asked for bank transfer, they mean it. Stripe supports this through what it calls the customer balance, and the mechanics are nicer than I expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stripe gives each customer a unique virtual bank account number and puts it on the invoice PDF and the hosted invoice page. Your real bank details are never on the invoice.&lt;/li&gt;
&lt;li&gt;The same customer gets the same account details on every invoice, so their finance team can save you as a payee once.&lt;/li&gt;
&lt;li&gt;When money arrives, Stripe tries to match it to an invoice automatically. First by the invoice number in the transfer reference, then by exact amount against the oldest open invoice, then a couple of fallback rules.&lt;/li&gt;
&lt;li&gt;If it can't match, the money sits in that customer's cash balance and you get a &lt;code&gt;cash_balance.funds_available&lt;/code&gt; webhook. You reconcile it by hand.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few details worth knowing before the first one arrives:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Underpayments.&lt;/strong&gt; If the customer sends less than the invoice total, the invoice stays open until the full amount is covered. This happens a lot with international wires, where a bank fee gets taken in the middle. There's a setting (Settings &amp;gt; Billing &amp;gt; Invoices, under Adjustments and reconciliation) to automatically write off small differences below a threshold you pick.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Overpayments and leftovers.&lt;/strong&gt; Extra money goes into the customer's cash balance. If funds sit unreconciled for 75 days, Stripe tries to send them back to the customer's bank. If they're still there at 90 days, Stripe moves them to your Stripe balance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Currencies and countries.&lt;/strong&gt; Bank transfers work for EUR, GBP, JPY, MXN and USD, and which ones you can offer depends on where your Stripe account is. In the Dashboard you can only make bank transfer a default payment method for USD and GBP invoices, for other currencies you add it per invoice. International SWIFT wires are listed as US-only, in USD.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EU transfers.&lt;/strong&gt; Stripe's docs warn that the beneficiary name the customer types should match your business name on Stripe exactly, or the payment can get held for extra screening. Put your registered business name in the reply email so they copy it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refunds and reversals.&lt;/strong&gt; Bank transfer payments mostly can't be pulled back the way card payments can (the docs list USD and CAD as the exceptions). And Stripe says it doesn't support refunds for international wires, you'd handle those yourself.&lt;/p&gt;

&lt;p&gt;Stripe's page on &lt;a href="https://docs.stripe.com/invoicing/bank-transfer" rel="noopener noreferrer"&gt;accepting bank transfers on invoices&lt;/a&gt; has the full matching rules and the API calls. To turn it on for subscription invoices you set &lt;code&gt;payment_settings[payment_method_types]&lt;/code&gt; to include &lt;code&gt;customer_balance&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fee math (why finance teams ask for this)
&lt;/h2&gt;

&lt;p&gt;Using the US list prices on Stripe's pricing pages as I read them in October 2026 (yours depend on your country and any custom pricing, so check):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Card: 2.9% + 30¢. On $2,736 that's about $79.64.&lt;/li&gt;
&lt;li&gt;USD bank transfer: 0.5%, capped at $5. On $2,736 that's $5. Stripe's page also lists $15 per wire payment, so a wire rather than an ACH transfer costs more.&lt;/li&gt;
&lt;li&gt;Stripe Billing itself (pay as you go) is 0.7% of Billing volume, and it applies whichever way they pay. Roughly $19 here.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So on an annual plan of this size, bank transfer saves you about $75 versus a card. Not life changing, but real. On a $19 monthly invoice for one seat the difference is pennies and the extra admin isn't worth it. That's why I'd only offer invoicing on annual plans or above some team size.&lt;/p&gt;

&lt;h2&gt;
  
  
  When they pay straight into your bank
&lt;/h2&gt;

&lt;p&gt;Some finance teams will ignore the virtual account and pay your normal business account because "that's who we have on file". Stripe can't see that money. You mark the invoice as paid yourself, in the Dashboard or with &lt;code&gt;paid_out_of_band=true&lt;/code&gt; on the pay endpoint, which marks it paid without charging anything.&lt;/p&gt;

&lt;p&gt;Two cautions. Do this only after the money is actually in your account, not when they say it's been sent. And the Billing pricing page says the 0.7% covers Billing volume "processed on and off Stripe", so marking it paid outside Stripe doesn't obviously get you out of that fee. I'd check with Stripe on your own plan before assuming.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving an existing card customer to invoices
&lt;/h2&gt;

&lt;p&gt;Often it's an existing monthly customer who's upgrading to annual and switching how they pay at the same time. Changing &lt;code&gt;collection_method&lt;/code&gt; on a live subscription is allowed, and per the collection method docs only invoices created after the change use the new method. Roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.stripe.com/v1/subscriptions/sub_123 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"sk_live_...:"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;collection_method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;send_invoice &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;days_until_due&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"payment_settings[payment_method_types][0]=customer_balance"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"payment_settings[payment_method_types][1]=card"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The monthly to annual switch is a separate change with its own proration and billing date questions, so do one at a time and look at the upcoming invoice before you confirm anything to the customer. Also make sure the customer record has the right billing email. With &lt;code&gt;send_invoice&lt;/code&gt; the invoice goes to that email, and if it's the office manager's personal address instead of accounts@ it'll sit in the wrong inbox for 30 days.&lt;/p&gt;

&lt;p&gt;One more thing: invoice-paid subscriptions don't support the newer Stripe pause (it requires &lt;code&gt;charge_automatically&lt;/code&gt;). If an invoice customer later asks to pause, you'll be looking at a different option.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when they don't pay
&lt;/h2&gt;

&lt;p&gt;Card subscriptions have retries. Invoices don't really, the customer has to act. What Stripe gives you instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reminders.&lt;/strong&gt; Up to three reminder emails, from 10 days before the due date to 60 days after (Settings &amp;gt; Billing &amp;gt; Subscriptions and emails, "Send reminders if a recurring invoice hasn't been paid").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What happens to the subscription.&lt;/strong&gt; 30, 60 or 90 days after the invoice is past due you can have Stripe cancel the subscription, mark it &lt;code&gt;unpaid&lt;/code&gt;, or leave it &lt;code&gt;past_due&lt;/code&gt;. With &lt;code&gt;unpaid&lt;/code&gt;, new invoices keep getting created but stay as drafts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At under 50 customers I wouldn't let a robot handle this one alone. A $2,736 invoice that's 10 days late deserves a short personal email to whoever asked for it, not just a Stripe reminder to &lt;a href="mailto:accounts@"&gt;accounts@&lt;/a&gt;. Usually it's stuck in an approval queue and one nudge fixes it. If you're going to write these emails anyway, I put together &lt;a href="https://indiehunt.io/studio/how-to-write-billing-emails-that-cut-support-tickets?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=stripe-pay-by-invoice" rel="noopener noreferrer"&gt;a playbook on billing emails that cut support tickets&lt;/a&gt; that covers renewal and receipt emails too.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small invoicing policy
&lt;/h2&gt;

&lt;p&gt;Write it once, paste it into the reply next time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Who can pay by invoice.&lt;/strong&gt; Annual plans only, or maybe 5+ seats. Monthly stays on card.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terms.&lt;/strong&gt; Net 30 for renewals, net 7 or net 14 for a first invoice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access.&lt;/strong&gt; On trust from day one, or after first payment. Pick one and make your paywall match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How to pay.&lt;/strong&gt; Bank transfer to the details on the invoice, invoice number in the reference. Card on the same page works too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What happens if it's late.&lt;/strong&gt; A personal email at about 7 days overdue, access paused at about 30, and what that looks like for their team.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paperwork.&lt;/strong&gt; Company name, address, tax ID collected before the first invoice is finalized.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then the reply to the agency writes itself:&lt;/p&gt;

&lt;p&gt;"Happy to. I'll set the team up on the annual plan for 12 seats and send an invoice for $2,736 with 30 day terms. It'll come from Stripe with bank transfer details, please put the invoice number in the transfer reference so it matches automatically. Can you send me the company name, billing address and the email the invoice should go to? Your team keeps access while the invoice is open."&lt;/p&gt;

&lt;p&gt;And once a company is paying for 12 seats, they'll start asking for roles, shared workspaces and someone other than the office manager who can see the invoices. If your product was built for one person per account, I wrote about &lt;a href="https://makerhunt.io/studio/how-to-add-team-accounts-to-a-product-built-for-one-user?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=stripe-pay-by-invoice" rel="noopener noreferrer"&gt;adding team accounts to a product built for one user&lt;/a&gt; and what to change first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Decide who's allowed to pay by invoice (annual, team size)&lt;/li&gt;
&lt;li&gt;[ ] Set &lt;code&gt;collection_method=send_invoice&lt;/code&gt; and &lt;code&gt;days_until_due&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Remember &lt;code&gt;send_invoice&lt;/code&gt; subscriptions are &lt;code&gt;active&lt;/code&gt; before they're paid, and fix the paywall to match your policy&lt;/li&gt;
&lt;li&gt;[ ] Turn on bank transfers (&lt;code&gt;customer_balance&lt;/code&gt;) and check your currency is supported&lt;/li&gt;
&lt;li&gt;[ ] Billing email on the customer is the finance inbox, company details set before finalizing&lt;/li&gt;
&lt;li&gt;[ ] Reminders on, plus your own personal email when it's late&lt;/li&gt;
&lt;li&gt;[ ] Know what to do with underpayments, overpayments and money that lands in your own bank account&lt;/li&gt;
&lt;li&gt;[ ] Decide what Stripe does at 30/60/90 days past due&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So, a question for anyone who already sells to teams: when a customer pays by invoice, do you give access the day the invoice goes out, or only once it's paid? And what &lt;code&gt;days_until_due&lt;/code&gt; did you end up using for the first invoice?&lt;/p&gt;

</description>
      <category>saas</category>
      <category>indiehackers</category>
      <category>stripe</category>
      <category>payments</category>
    </item>
    <item>
      <title>When a Customer Asks to Pause Their Subscription and You Have Under 50 Customers</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Sat, 24 Oct 2026 05:16:48 +0000</pubDate>
      <link>https://dev.to/posting-dude/when-a-customer-asks-to-pause-their-subscription-and-you-have-under-50-customers-214b</link>
      <guid>https://dev.to/posting-dude/when-a-customer-asks-to-pause-their-subscription-and-you-have-under-50-customers-214b</guid>
      <description>&lt;p&gt;Say you sell a small tool to wedding photographers. You have 40-something paying customers, mostly monthly. In November one of them writes:&lt;/p&gt;

&lt;p&gt;"Season's done for me. Can I pause until March instead of cancelling? I don't want to lose my setup."&lt;/p&gt;

&lt;p&gt;That's a good email to get. They're not leaving, they just don't want to pay for four months of nothing. But if you've never done a pause before, you open the Stripe Dashboard and find there are now two different buttons with "pause" in the name, and they do very different things. Pick the wrong one and you either keep charging them (well, invoicing them) all winter, or you lock someone out who only wanted a payment break.&lt;/p&gt;

&lt;p&gt;This post is the version I'd want before answering that email. What each pause actually does in Stripe, what your app has to check, and a small pause policy you can write once and reuse. With under 50 customers every pause is a person you'll talk to again in March, so it's worth getting right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stripe has two kinds of pause now
&lt;/h2&gt;

&lt;p&gt;This changed recently, so older blog posts are out of date. For years the only "pause" was &lt;code&gt;pause_collection&lt;/code&gt;. On 2026-09-30 Stripe's changelog added the ability to pause and resume subscriptions on demand (it was in preview earlier this year). So now:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Pause payment collection&lt;/strong&gt; (&lt;code&gt;pause_collection&lt;/code&gt; on the subscription)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The subscription stays &lt;code&gt;active&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Invoices still get created every period, Stripe just doesn't collect them.&lt;/li&gt;
&lt;li&gt;The customer keeps access, because nothing about the status changed.&lt;/li&gt;
&lt;li&gt;Stripe's own docs describe it as a grace period, for when "a customer needs more time to pay or can't pay for one or more billing periods."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;2. Pause the subscription&lt;/strong&gt; (the newer pause endpoint, or "Pause subscription" in the Dashboard)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The status changes to &lt;code&gt;paused&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Stripe stops generating invoices until you resume.&lt;/li&gt;
&lt;li&gt;It's meant to stop service too. The docs literally say to make clear that pausing "stops both service delivery and invoice generation."&lt;/li&gt;
&lt;li&gt;It needs the subscription to be on flexible billing mode, and a few other things (more on that below).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the photographer who's away until March wants #2. A customer who says "money is tight this month, can I keep using it and pay later" wants #1. Most of the confusion is just using one for the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The easy one: pausing payment collection
&lt;/h2&gt;

&lt;p&gt;You can do this from the Dashboard (open the subscription, the ⋯ menu, &lt;strong&gt;Pause payment collection&lt;/strong&gt;) or with one API call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.stripe.com/v1/subscriptions/sub_123 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"sk_live_...:"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"pause_collection[behavior]=mark_uncollectible"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"pause_collection[resumes_at]=1803859200"&lt;/span&gt;  &lt;span class="c"&gt;# 2027-03-01 00:00 UTC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;resumes_at&lt;/code&gt; is optional. Leave it out and collection stays paused until you unset it yourself, which is how you end up with a customer who quietly used the product free for a year because nobody remembered.&lt;/p&gt;

&lt;p&gt;The part people skip is &lt;code&gt;behavior&lt;/code&gt;. It decides what happens to the invoices Stripe keeps creating while collection is paused:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;behavior&lt;/th&gt;
&lt;th&gt;what happens to each invoice&lt;/th&gt;
&lt;th&gt;use it when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;keep_as_draft&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;stays a draft, &lt;code&gt;auto_advance&lt;/code&gt; off&lt;/td&gt;
&lt;td&gt;you're deferring, they'll pay later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mark_uncollectible&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;marked uncollectible (customer balance applied first)&lt;/td&gt;
&lt;td&gt;you're forgiving the period&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;void&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;voided, no record of a charge&lt;/td&gt;
&lt;td&gt;you're forgiving and want no trail&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things in the docs that are easy to miss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;keep_as_draft&lt;/code&gt; means they still owe you.&lt;/strong&gt; When you resume, the drafts don't collect themselves. You have to turn on automatic collection for each one (&lt;code&gt;auto_advance=true&lt;/code&gt;), and then they get charged for the paused months too. If you told the customer "no charge while paused", don't pick this one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invoices from before the pause keep getting retried&lt;/strong&gt; unless you void them. So if the customer already had a failed renewal and you pause collection to be nice, Stripe can still retry that old invoice. Look at their open invoices before you click.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also, while collection is paused Stripe doesn't send upcoming invoice emails or webhooks for those invoices. Quiet is nice, but it also means your own "your plan renews soon" logic, if it hangs off those webhooks, goes silent for that customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real pause: status becomes &lt;code&gt;paused&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;In the Dashboard it's the ⋯ menu, &lt;strong&gt;Pause subscription&lt;/strong&gt;. You choose whether it starts now or on a date, and whether it stays paused "until you resume it" or until a specific date. The date option is the one you want for the photographer: pause now, resume March 1.&lt;/p&gt;

&lt;p&gt;What happens when you pause, per Stripe's docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;status changes to &lt;code&gt;paused&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;current_period_end&lt;/code&gt; gets moved to the moment you paused&lt;/li&gt;
&lt;li&gt;no new invoices until resume&lt;/li&gt;
&lt;li&gt;you get &lt;code&gt;customer.subscription.paused&lt;/code&gt; (plus &lt;code&gt;customer.subscription.updated&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the money part. By default Stripe credits the unused time, from the pause moment to the end of the period, as a pending invoice item. Stripe's example: $30/month plan, paused on day 15, you get a -$15 pending item. Resume on day 45 and the resumption invoice has the -$15 credit plus a fresh $30 period, so they pay $15 and a new cycle starts. You can change that with &lt;code&gt;bill_for[unused_time_from]&lt;/code&gt; (&lt;code&gt;now&lt;/code&gt;, &lt;code&gt;item_current_period_start&lt;/code&gt;, or &lt;code&gt;none&lt;/code&gt;), and &lt;code&gt;invoicing_behavior&lt;/code&gt; decides if the credit sits as a pending item (default) or gets invoiced right away.&lt;/p&gt;

&lt;p&gt;If you'd rather see the numbers before you commit, Stripe lets you preview a pause invoice, but only if &lt;code&gt;invoicing_behavior&lt;/code&gt; is &lt;code&gt;invoice&lt;/code&gt;. With the default it returns a 404, which is easy to read as "something's broken", so heads up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the pause button might be greyed out
&lt;/h3&gt;

&lt;p&gt;Stripe lists the cases where you can't pause:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the subscription uses &lt;code&gt;send_invoice&lt;/code&gt; instead of charging automatically&lt;/li&gt;
&lt;li&gt;it's on classic billing mode (you'd migrate it to flexible first)&lt;/li&gt;
&lt;li&gt;it's in a free trial set with &lt;code&gt;trial_end&lt;/code&gt; / &lt;code&gt;trial_period_days&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;status is &lt;code&gt;unpaid&lt;/code&gt;, &lt;code&gt;incomplete&lt;/code&gt;, &lt;code&gt;incomplete_expired&lt;/code&gt; or &lt;code&gt;canceled&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;it has a subscription schedule attached (you pause through the schedule instead)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On billing mode: per Stripe's docs, API version &lt;code&gt;2025-09-30.clover&lt;/code&gt; and later default new subscriptions to flexible, older versions default to classic, and Dashboard-created ones follow a setting under Settings &amp;gt; Billing &amp;gt; Subscriptions and emails. If you set things up a while ago there's a fair chance your early subscribers are classic. Migration is one way only (flexible can't go back to classic), so read Stripe's compare page before migrating everyone just to get a pause button. For one customer who wants a pause next week, &lt;code&gt;pause_collection&lt;/code&gt; plus a note might honestly be fine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Resuming without a surprise
&lt;/h3&gt;

&lt;p&gt;The resume step has a default that I'd change. The &lt;code&gt;payment_behavior&lt;/code&gt; options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;resume_on_payment_attempt&lt;/code&gt; (default): the resume call doesn't try to charge. If the charge on that invoice fails later, the subscription goes to &lt;code&gt;past_due&lt;/code&gt; and Stripe doesn't retry.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;resume_on_payment_success&lt;/code&gt; (Stripe calls it recommended): it tries the payment in the request, stays &lt;code&gt;paused&lt;/code&gt; until payment works, and retries.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also, a resume request can return 200 while the subscription is still &lt;code&gt;paused&lt;/code&gt; and the invoice is waiting to be paid. So don't flip access back on just because the API call didn't throw. Check the returned &lt;code&gt;status&lt;/code&gt;, or wait for &lt;code&gt;customer.subscription.resumed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One small trap: if the customer has a coupon, it keeps counting down during the pause. A three month launch discount paused for three months is just gone when they come back. If you promised it, you'll need to fix that by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What your app checks
&lt;/h2&gt;

&lt;p&gt;This is where most of the bugs are. Stripe recommends using the subscription status to start and stop service. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Collection paused → status is still &lt;code&gt;active&lt;/code&gt; → your paywall lets them in. That's what you want for a grace period.&lt;/li&gt;
&lt;li&gt;Subscription paused → status is &lt;code&gt;paused&lt;/code&gt; → your paywall should let them read their stuff but not use the paid features. Your call, but decide it before the first pause, not during.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The other catch: &lt;code&gt;paused&lt;/code&gt; isn't only from a pause request. A trial that ends without a card can also put a subscription in &lt;code&gt;paused&lt;/code&gt;. If your app shows "Add a card to continue your trial" for every &lt;code&gt;paused&lt;/code&gt;, your photographer gets told their trial ended. Stripe gives you &lt;code&gt;status_details.paused.subscription.type&lt;/code&gt; to tell them apart: &lt;code&gt;pause_requested&lt;/code&gt;, &lt;code&gt;trial_end_without_payment_method&lt;/code&gt;, &lt;code&gt;system&lt;/code&gt;, and with the auto pause after payment failures added in the same release, &lt;code&gt;first_payment_failure&lt;/code&gt; and &lt;code&gt;final_payment_failure&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Rough version of the access check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Subscription&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;trialing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pause_collection&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;full_grace&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;full&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;paused&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;why&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status_details&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;subscription&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;why&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;trial_end_without_payment_method&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;add_card&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;why&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;first_payment_failure&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;why&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;final_payment_failure&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fix_payment&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;paused_readonly&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// pause_requested, system&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// past_due etc. handled the way you already do&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Check the field names against your SDK version, &lt;code&gt;status_details&lt;/code&gt; came with the newer API versions.) I went through what every status should mean for access in &lt;a href="https://dev.to/posting-dude/stripe-has-8-subscription-statuses-your-paywall-probably-checks-one-aj1"&gt;how Stripe's subscription statuses map to access&lt;/a&gt;, and &lt;code&gt;paused&lt;/code&gt; was the one most paywalls forget.&lt;/p&gt;

&lt;h2&gt;
  
  
  The customer can't do this themselves (yet)
&lt;/h2&gt;

&lt;p&gt;In Stripe's Customer Portal configuration docs there are toggles for switching plans, quantities, cancelling, cancellation reasons and retention coupons. I didn't find a pause option. So under 50 customers, a pause usually starts as an email to you, and that's fine. A support email at this size is a conversation, and it's often the only time a quiet customer tells you how they use the thing.&lt;/p&gt;

&lt;p&gt;If you do want pause as self serve, put it in your own cancel flow. Offer it only when the reason fits (seasonal, project ended, taking a break) and never as a wall in front of the cancel button. I wrote more about &lt;a href="https://auraplusplus.com/studio/how-to-design-a-saas-cancel-flow-without-dark-patterns?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=stripe-pause-subscription" rel="noopener noreferrer"&gt;a cancel flow that offers pause without dark patterns&lt;/a&gt;, and the short version is: pause is for "not right now", cancel is for "not for me", and mixing them makes people angry later.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pause policy you can write in ten minutes
&lt;/h2&gt;

&lt;p&gt;Write this down once, in a note, before the second pause request:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Which pause.&lt;/strong&gt; "Away / not using it" gets a real pause. "Can't pay right now but still using it" gets paused collection, with &lt;code&gt;mark_uncollectible&lt;/code&gt; if you're forgiving and &lt;code&gt;keep_as_draft&lt;/code&gt; only if you said they'd pay later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Max length.&lt;/strong&gt; Something like up to 3 months, with a resume date always set. "Until you resume it" is how pauses turn into forgotten accounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What they see while paused.&lt;/strong&gt; Read-only, export still works, nothing deleted. Say this in the reply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What the first invoice after looks like.&lt;/strong&gt; If you keep the default credit, tell them the first charge in March will be smaller than usual, or they'll think it's a mistake.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coupons.&lt;/strong&gt; Mention it if a discount will run out during the pause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reminder.&lt;/strong&gt; Put a calendar reminder a few days before the resume date and send a short "you're back on March 1, here's what you'll be charged" email. I didn't see anything in the pause docs about Stripe emailing the customer before a resume, so I'd send this one yourself.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reply to the photographer then writes itself:&lt;/p&gt;

&lt;p&gt;"Sure, I've paused your subscription from today until March 1. You won't be charged before then and your projects stay as they are, you can still log in and look at them. Your first charge in March will be a bit lower because of the unused days from this month. I'll email you a few days before."&lt;/p&gt;

&lt;p&gt;That's it. No retention trick, no form.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Know which pause you're using: &lt;code&gt;pause_collection&lt;/code&gt; (stays active, invoices still created) or real pause (&lt;code&gt;paused&lt;/code&gt;, no invoices)&lt;/li&gt;
&lt;li&gt;[ ] For &lt;code&gt;pause_collection&lt;/code&gt;, pick &lt;code&gt;behavior&lt;/code&gt; on purpose, and set &lt;code&gt;resumes_at&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Look at open invoices before pausing, old ones keep retrying&lt;/li&gt;
&lt;li&gt;[ ] For real pause: flexible billing mode, &lt;code&gt;charge_automatically&lt;/code&gt;, no trial, no schedule&lt;/li&gt;
&lt;li&gt;[ ] Resume with &lt;code&gt;resume_on_payment_success&lt;/code&gt;, confirm &lt;code&gt;status&lt;/code&gt; before giving access back&lt;/li&gt;
&lt;li&gt;[ ] Paywall handles &lt;code&gt;paused&lt;/code&gt; and reads &lt;code&gt;status_details&lt;/code&gt; so a paused customer doesn't see trial copy&lt;/li&gt;
&lt;li&gt;[ ] Coupons keep running during a pause&lt;/li&gt;
&lt;li&gt;[ ] Reminder email before resume&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stripe's &lt;a href="https://docs.stripe.com/billing/subscriptions/pause" rel="noopener noreferrer"&gt;pause subscriptions docs&lt;/a&gt; are the source of truth for the fields above, and the pages changed recently, so check them before you build anything. And since the same release also lets Stripe pause a subscription automatically after a failed payment, it's worth deciding how that fits with your retry emails. I put together a longer playbook on &lt;a href="https://auraplusplus.com/studio/how-to-recover-failed-saas-payments-before-churn?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=stripe-pause-subscription" rel="noopener noreferrer"&gt;recovering failed payments before they turn into churn&lt;/a&gt; if you're setting that up anyway.&lt;/p&gt;

&lt;p&gt;So, a question for anyone already doing this: when a customer asks to pause, do you use &lt;code&gt;pause_collection&lt;/code&gt;, the newer pause, or do you just cancel and re-subscribe them later? And if it's &lt;code&gt;pause_collection&lt;/code&gt;, which &lt;code&gt;behavior&lt;/code&gt; did you go with?&lt;/p&gt;

</description>
      <category>saas</category>
      <category>indiehackers</category>
      <category>stripe</category>
      <category>payments</category>
    </item>
    <item>
      <title>When a Customer Asks for an Invoice With Their VAT Number and You Have Under 50 Customers</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Fri, 23 Oct 2026 05:15:44 +0000</pubDate>
      <link>https://dev.to/posting-dude/when-a-customer-asks-for-an-invoice-with-their-vat-number-and-you-have-under-50-customers-f5h</link>
      <guid>https://dev.to/posting-dude/when-a-customer-asks-for-an-invoice-with-their-vat-number-and-you-have-under-50-customers-f5h</guid>
      <description>&lt;p&gt;Say you have 30-something paying customers and one of them, a small agency in Germany, sends this email on a Monday:&lt;/p&gt;

&lt;p&gt;"Hi, can you resend last month's invoice with our company name and VAT number on it? Our accountant needs it. VAT ID is DE..."&lt;/p&gt;

&lt;p&gt;It feels like a two minute job. Then you find out Stripe won't let you edit that invoice, and you also start wondering if you were supposed to charge them VAT in the first place.&lt;/p&gt;

&lt;p&gt;This post is the Stripe side of that email. What you can fix for the past invoice, how to make every future invoice right without touching it again, and the tax question you should take to an accountant instead of guessing. I'm not an accountant, so expect a few "check with yours".&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the customer actually cares
&lt;/h2&gt;

&lt;p&gt;For a business buyer an invoice is not a nice PDF, it's a document their bookkeeping depends on. The European Commission's invoicing rules list what a full VAT invoice should contain: date, a unique sequential number, both parties' full names and addresses, the customer's VAT number if the customer is the one liable for the tax, and the words "reverse charge" when the reverse charge applies.&lt;/p&gt;

&lt;p&gt;So when the agency asks for their VAT number on the invoice, they're doing exactly what any decent bookkeeping setup tells them to do. I wrote &lt;a href="https://auraplusplus.com/studio/how-to-set-up-bookkeeping-for-your-saas-as-a-solo-founder?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=stripe-vat-invoice" rel="noopener noreferrer"&gt;a guide to setting up bookkeeping as a solo founder&lt;/a&gt; and one of the rules in it is the same thing from the other side: if you're VAT registered, make sure the invoices you receive show the vendor's tax details. Your customer is following that rule. Your invoice is the thing in their way.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, check what they got
&lt;/h2&gt;

&lt;p&gt;Two different documents get mixed up here.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Subscriptions&lt;/strong&gt; generate invoices automatically. If they pay you monthly through Stripe Billing, there is an invoice for every period already.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-time payments through Checkout&lt;/strong&gt; don't get an invoice by default. They get a receipt. Stripe's receipts doc says you need to enable invoices for one-time payments, by setting &lt;code&gt;invoice_creation[enabled]=true&lt;/code&gt; when you create the Checkout Session.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you sell a one-off thing (a lifetime deal, a setup fee, credits) and you never turned that on, the customer only has a receipt. For a business buyer that's often not enough, so turn it on before the next sale.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old invoice: you probably can't edit it
&lt;/h2&gt;

&lt;p&gt;This is the part that surprises people.&lt;/p&gt;

&lt;p&gt;When an invoice is finalized, Stripe copies the customer's tax details onto it. The Invoice API reference says the &lt;code&gt;customer_tax_ids&lt;/code&gt; field matches &lt;code&gt;customer.tax_ids&lt;/code&gt; until the invoice is finalized, and after that it's no longer updated. So adding the VAT number to the customer today does nothing to last month's PDF.&lt;/p&gt;

&lt;p&gt;And you can't just revise it. Stripe's doc on editing finalized invoices says:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;invoices attached to subscriptions can't be revised after finalization (the Edit invoice button is disabled)&lt;/li&gt;
&lt;li&gt;invoices in &lt;code&gt;paid&lt;/code&gt; or &lt;code&gt;void&lt;/code&gt; status can't be revised at all&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Last month's subscription invoice that already got paid is both. Stripe also notes that in the EU you might want to void an invoice and issue a credit note instead of revising the original, and it tells you to ask your legal counsel.&lt;/p&gt;

&lt;p&gt;What I'd do with under 50 customers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add the VAT number and legal name to the customer now (next section), so the next invoice is right.&lt;/li&gt;
&lt;li&gt;Reply honestly: "Done for all future invoices. For last month's, I'm checking with my accountant on the correct way to reissue it."&lt;/li&gt;
&lt;li&gt;Ask your accountant whether a credit note plus a new invoice is needed, or whether the corrected next invoice is fine for them. The answer depends on your country, not on Stripe.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What I would not do is download the PDF and edit it in a PDF editor. Now you have an invoice that doesn't match your Stripe records or your accounting, with the same number as a different document.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add the tax ID to the customer
&lt;/h2&gt;

&lt;p&gt;In the Dashboard, open the customer, click the pencil next to Details, scroll to the Tax status and Tax ID fields, and add an ID with type and value. While you're there, set the name to their legal business name and fill in the billing address. Both names and addresses are on the EU list above, and the PDF shows what's on the customer.&lt;/p&gt;

&lt;p&gt;With the API it's one call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.stripe.com/v1/tax_ids &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"sk_live_...:"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eu_vat &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;DE123456789 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"owner[type]=customer"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"owner[customer]=cus_..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can't edit a tax ID. To change one, you delete it and create a new one.&lt;/p&gt;

&lt;p&gt;Then Stripe checks the number. Per Stripe's page on &lt;a href="https://docs.stripe.com/billing/customer/tax-ids" rel="noopener noreferrer"&gt;customer tax IDs&lt;/a&gt;, EU VAT numbers get validated against the European Commission's VIES system and UK VAT numbers against HMRC. It's asynchronous: it usually takes a few seconds, Stripe retries if the government service is down, and you get a &lt;code&gt;customer.tax_id.updated&lt;/code&gt; event with &lt;code&gt;verification.status&lt;/code&gt; set to &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;verified&lt;/code&gt;, &lt;code&gt;unverified&lt;/code&gt; or &lt;code&gt;unavailable&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Three details from that page worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Valid number doesn't mean right company.&lt;/strong&gt; VIES only says the number is valid. You still need to check the name and address match. In the Dashboard you can hover over the VAT number to see what VIES returned, including the registered name and address.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;unverified&lt;/code&gt; is your call.&lt;/strong&gt; Stripe reports the result. What you do with a number that fails is up to you and your compliance needs. When it says &lt;code&gt;unavailable&lt;/code&gt;, you have to verify by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe doesn't keep re-checking.&lt;/strong&gt; It validates when you add the ID, not over time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For testing, Stripe has magic numbers. With an EU VAT, GB VAT or ABN type, &lt;code&gt;000000000&lt;/code&gt; verifies, &lt;code&gt;111111111&lt;/code&gt; fails, and &lt;code&gt;222222222&lt;/code&gt; stays pending forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop doing this by email
&lt;/h2&gt;

&lt;p&gt;One customer asking is a support ticket. Five is a missing field in your checkout.&lt;/p&gt;

&lt;p&gt;If you use Stripe Checkout, set &lt;code&gt;tax_id_collection[enabled]=true&lt;/code&gt; on the session. Checkout then shows fields for the tax ID and legal business name when the customer's location is supported. Some details from Stripe's Checkout tax ID doc:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The customer can enter one tax ID.&lt;/li&gt;
&lt;li&gt;It's for &lt;strong&gt;business&lt;/strong&gt; tax IDs only.&lt;/li&gt;
&lt;li&gt;If you pass an existing Customer, also set &lt;code&gt;customer_update[name]=auto&lt;/code&gt; so the business name they enter gets saved as the customer's name.&lt;/li&gt;
&lt;li&gt;If that customer already has a saved tax ID, Checkout won't show the form again.&lt;/li&gt;
&lt;li&gt;During the session Stripe only checks the format. The real validation happens after, and &lt;code&gt;checkout.session.completed&lt;/code&gt; can arrive before it finishes. So don't decide anything tax-related in that webhook on the assumption it's verified.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tax_id_collection[required]=if_supported&lt;/code&gt; makes it mandatory in supported countries. For a mostly-individual customer base I'd leave it optional, or you're adding a field nobody can fill.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For customers who are already subscribed, the Customer Portal has a Tax ID option that lets them update it themselves. It's off by default. If you have the portal running already (I wrote about &lt;a href="https://dev.to/posting-dude/turn-on-stripe-customer-portal-before-you-hit-50-customers-gg6"&gt;turning on Stripe Customer Portal before 50 customers&lt;/a&gt;), flip that one on once you have any business buyers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your own tax ID goes on the invoice too
&lt;/h2&gt;

&lt;p&gt;The EU list includes the supplier's details, so if you have a VAT or GST number, put it on your invoices. In the Dashboard it's Invoice settings, the Tax tab, add your ID, then use the overflow menu to set it as default. Default tax IDs then show on your invoice and credit note PDFs unless you override them per invoice.&lt;/p&gt;

&lt;p&gt;Same snapshot rule: you can't add, change or remove account tax IDs on an invoice after it's finalized. If you've been sending invoices without your number, the fix only applies going forward.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question behind the question: did you owe VAT?
&lt;/h2&gt;

&lt;p&gt;This is where you stop and call an accountant. But here's what Stripe does and doesn't do, so the conversation is short.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reverse charge.&lt;/strong&gt; Stripe's EU tax page explains it simply: in some cases when you sell services to a VAT-registered business in another EU country, the customer calculates and pays the VAT, and your invoice should say reverse charge instead of showing a tax amount. If the customer gives a domestic tax ID (same country as you), reverse charge doesn't apply and it's treated like a consumer sale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you use Stripe Tax&lt;/strong&gt;, it treats a customer with a valid-format EU VAT number as reverse charge and doesn't calculate tax. Note the wording on Stripe's tax ID page: Stripe Tax applies the reverse charge when the tax ID has the required number format, regardless of the government verification result. So an &lt;code&gt;unverified&lt;/code&gt; number still gets zero tax unless you do something about it. Another reason to look at the VIES result yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you don't use Stripe Tax&lt;/strong&gt;, there's a customer-level setting. On the Customer object, &lt;code&gt;tax_exempt&lt;/code&gt; can be &lt;code&gt;none&lt;/code&gt;, &lt;code&gt;exempt&lt;/code&gt; or &lt;code&gt;reverse&lt;/code&gt;, and when it's &lt;code&gt;reverse&lt;/code&gt;, invoice and receipt PDFs include the text "Reverse charge". It's a label. It doesn't make a sale reverse-chargeable, it only prints the words.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stripe Tax only calculates where you're registered.&lt;/strong&gt; If you have no registration for a location, the calculation returns zero tax, with &lt;code&gt;taxability_reason&lt;/code&gt; set to &lt;code&gt;not_collecting&lt;/code&gt;. So turning on Stripe Tax doesn't mean you're collecting anything yet. You add registrations in the Dashboard after you've actually registered with the tax authority. As I write this, Stripe's pricing page lists 0.5% per transaction where you're registered for the no-code integrations (Billing, Checkout, Invoicing, Payment Links). Check your own country's page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where the money question really sits.&lt;/strong&gt; The B2B sale to the German agency is often the easy case. The harder one is consumers. Stripe's EU page says that if your business is outside the EU, you might need to register to collect VAT from your first sale in an EU country, and it describes the non-Union One Stop Shop scheme for non-EU businesses selling services to individuals, so you file one return instead of one per country. EU-based businesses have a 10,000 EUR a year threshold: under it (and a couple of other conditions), cross-border sales to individuals can stay on home-country VAT. Stripe's Thresholds page in the Tax Dashboard can show you where you're getting close, but it only highlights potential obligations. Confirming them is on you.&lt;/p&gt;

&lt;p&gt;If reading that paragraph makes you tired, that's the honest case for a merchant of record. With an MoR the provider is the legal seller, so it handles VAT and sales tax on its own invoices, for a higher fee per sale than plain Stripe. I compared &lt;a href="https://sidehunt.io/studio/how-to-start-charging-for-a-side-project-with-a-day-job?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=stripe-vat-invoice" rel="noopener noreferrer"&gt;Stripe and merchant of record options&lt;/a&gt; in a guide about charging for a side project, with the fees as they were in October. Small SaaS with lots of individual buyers abroad is exactly where it's worth a look.&lt;/p&gt;

&lt;h2&gt;
  
  
  A short checklist for the next VAT email
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Reply the same day: future invoices will be fixed, the past one is with your accountant.&lt;/li&gt;
&lt;li&gt;Add tax ID, legal name and billing address to the Customer.&lt;/li&gt;
&lt;li&gt;Check the VIES result and the registered name in the Dashboard.&lt;/li&gt;
&lt;li&gt;Add your own tax ID in Invoice settings and set it as default.&lt;/li&gt;
&lt;li&gt;Turn on &lt;code&gt;invoice_creation&lt;/code&gt; for one-time Checkout payments.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;tax_id_collection[enabled]=true&lt;/code&gt; to Checkout, with &lt;code&gt;customer_update[name]=auto&lt;/code&gt; for existing customers.&lt;/li&gt;
&lt;li&gt;Turn on the Tax ID option in the Customer Portal.&lt;/li&gt;
&lt;li&gt;Book one call with an accountant about registration, reverse charge and how to reissue old invoices. Bring a list of your customers by country.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exciting, but it's maybe an hour of work, and it turns "can you resend the invoice with our VAT number" into an email you never get again.&lt;/p&gt;

&lt;p&gt;So, a narrow question for anyone selling to businesses: do you collect VAT IDs in Checkout with &lt;code&gt;tax_id_collection&lt;/code&gt;, or do you still add them by hand when someone emails? And if you made it a Checkout field, did anyone actually fill it in?&lt;/p&gt;

</description>
      <category>saas</category>
      <category>indiehackers</category>
      <category>stripe</category>
      <category>payments</category>
    </item>
    <item>
      <title>When Bots Start Card-Testing Your Checkout and You Have Under 100 Customers</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Thu, 22 Oct 2026 05:15:02 +0000</pubDate>
      <link>https://dev.to/posting-dude/when-bots-start-card-testing-your-checkout-and-you-have-under-100-customers-5e46</link>
      <guid>https://dev.to/posting-dude/when-bots-start-card-testing-your-checkout-and-you-have-under-100-customers-5e46</guid>
      <description>&lt;p&gt;Say you open the Stripe dashboard on a Tuesday morning and the home chart has a spike you didn't earn. Two hundred payment attempts overnight, almost all failed, a few succeeded for your $1 starter plan. The customer names look like someone fell on a keyboard. Your actual business does maybe 40 payments a month.&lt;/p&gt;

&lt;p&gt;That's card testing. Someone has a list of stolen card numbers and is using your checkout to find out which ones still work. Your product doesn't matter to them. Your form is just a cheap way to ask a bank "is this card alive?"&lt;/p&gt;

&lt;p&gt;With under 100 customers this hits harder than it does for a big store. Your normal traffic is so small that a few hundred junk attempts can make your whole account look risky. So set a few things before launch day, not after the first spike.&lt;/p&gt;

&lt;p&gt;I wrote earlier about &lt;a href="https://dev.to/posting-dude/fake-signups-and-card-testing-a-small-defense-stack-for-a-tiny-saas-g21"&gt;the signup side of this, fake accounts and junk signups&lt;/a&gt;. This one is only the Stripe part.&lt;/p&gt;

&lt;h2&gt;
  
  
  What card testing looks like in Stripe
&lt;/h2&gt;

&lt;p&gt;Stripe's own &lt;a href="https://docs.stripe.com/disputes/prevention/card-testing" rel="noopener noreferrer"&gt;guide to protecting yourself from card testing&lt;/a&gt; lists the symptoms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a spike in failed or blocked payments on the dashboard home page and the transactions list&lt;/li&gt;
&lt;li&gt;a spike of 402 errors in your developer logs, a lot of them with &lt;code&gt;generic_decline&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;small payments that succeeded, usually with nonsense names and emails&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a small SaaS I'd also look for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one Customer object with many different cards attached&lt;/li&gt;
&lt;li&gt;lots of new Customers created from the same IP in a short window&lt;/li&gt;
&lt;li&gt;a pile of SetupIntents with no subscription ever starting after them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one matters. Stripe says testers &lt;em&gt;prefer&lt;/em&gt; card setup flows, because validating a card during setup usually doesn't show up on the cardholder's statement, so the real owner doesn't notice. A "start your free trial, card required" form is a card setup flow. So is "add a payment method" on a settings page that anyone can reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why tiny SaaS checkouts are a nice target
&lt;/h2&gt;

&lt;p&gt;Small products tend to have exactly what testers look for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free trial with card up front.&lt;/strong&gt; A SetupIntent or a $0 trial checkout checks the card and charges nothing. Perfect for them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;$1 or very cheap plans.&lt;/strong&gt; Testers like low amounts because cardholders are less likely to notice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An open endpoint that creates PaymentIntents, SetupIntents or Checkout Sessions.&lt;/strong&gt; If any visitor (or any script) can hit &lt;code&gt;/api/create-checkout-session&lt;/code&gt; without logging in, that's a card checker with your name on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low volume.&lt;/strong&gt; Your normal decline rate is built on a tiny base, so a burst moves it a lot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stripe also says testers can use your publishable key against your site, and that simple IP blocks alone usually aren't enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Stripe already does before you touch anything
&lt;/h2&gt;

&lt;p&gt;If you use Stripe Checkout or the latest Payment Element, Stripe runs card testing controls on its side: rate limiters, AI models, CAPTCHA triggers when it detects an attack. Payments it stops show up as &lt;code&gt;Blocked by Stripe&lt;/code&gt;. A hand-rolled card form against the raw API gets less of that.&lt;/p&gt;

&lt;p&gt;On plans: Stripe's &lt;a href="https://stripe.com/radar/pricing" rel="noopener noreferrer"&gt;Radar pricing page&lt;/a&gt; lists Radar Lite as included at no extra charge on standard payments pricing, with AI-based fraud prevention for card payments, card testing prevention and fraud alerts. Card testing prevention is in every tier. What you &lt;em&gt;don't&lt;/em&gt; get on the free tier, or on Radar Standard, is custom rules with backtesting. Those are in Radar Plus and Radar Pro (if you've read older posts that say "Radar for Fraud Teams", that's the feature set they mean). As I write this, the US page shows Plus starting at $14 a month or pay as you go. Check your own country.&lt;/p&gt;

&lt;p&gt;Two cheap things that help the default protection work better:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Send Stripe more data.&lt;/strong&gt; Stripe says the models do better when you pass IP address, email, name and billing address, and it recommends loading Stripe.js on every page, not only checkout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn on Radar for saved cards.&lt;/strong&gt; Per Stripe's page on &lt;a href="https://docs.stripe.com/radar/optimize-risk-factors" rel="noopener noreferrer"&gt;optimizing Radar risk factors&lt;/a&gt;, Radar by default does &lt;em&gt;not&lt;/em&gt; scan when you set up a payment method for future use without a charge. If your free trial collects a card with a SetupIntent, go to Radar settings and enable "Use Radar on payment methods saved for future use". Otherwise your trial form is the one door Radar isn't watching.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;While you're there, check the built-in CVC and postal code risk-score rules. Stripe says they also apply when attaching a card to a customer, so they help setup flows too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Radar rules worth adding (if your plan has custom rules)
&lt;/h2&gt;

&lt;p&gt;Rules use this shape: &lt;code&gt;{action} if {condition}&lt;/code&gt;. The &lt;a href="https://docs.stripe.com/radar/rules/reference" rel="noopener noreferrer"&gt;rules reference&lt;/a&gt; has the details. Three things from it worth knowing before you write anything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Velocity counts &lt;strong&gt;exclude the payment being evaluated&lt;/strong&gt;. So &lt;code&gt;total_charges_per_ip_address_hourly&lt;/code&gt; is 0 on the first attempt from an IP in the hour, 1 on the second.&lt;/li&gt;
&lt;li&gt;"Hourly" isn't exactly 60 minutes. Stripe uses 5-minute buckets, so it can be up to 3,900 seconds.&lt;/li&gt;
&lt;li&gt;Rules only affect future payments, not ones already processed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stripe's Radar Rules 101 guide gives these two as card testing rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Block if :total_charges_per_ip_address_hourly: &amp;gt; 1
Block if :blocked_charges_per_ip_address_hourly: &amp;gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second one is the "more aggressive" add-on. For a B2B SaaS where a whole office might share one IP, &lt;code&gt;&amp;gt; 1&lt;/code&gt; on the first rule can be too tight, so backtest it before turning it on.&lt;/p&gt;

&lt;p&gt;Since the trial is usually the weak spot, also use attributes that count SetupIntents. In the &lt;a href="https://docs.stripe.com/radar/rules/supported-attributes" rel="noopener noreferrer"&gt;supported attributes list&lt;/a&gt;, &lt;code&gt;card_count_for_ip_address_transactions_hourly&lt;/code&gt; counts cards from "transactions (charges and SetupIntents)", while &lt;code&gt;card_count_for_ip_address_hourly&lt;/code&gt; only counts charges. Some starting points (the thresholds are my guess for a small SaaS, not Stripe's numbers):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Block if :card_count_for_ip_address_transactions_hourly: &amp;gt; 3
Block if :card_count_for_customer_daily: &amp;gt; 2
Block if :declined_charges_per_ip_address_hourly: &amp;gt; 3
Request 3D Secure if :is_setup_intent: and :risk_level: != 'normal'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plain version: one IP trying four-plus cards in an hour, or one customer with three-plus cards in a day, is not a real customer. Someone who typos their card twice still gets through.&lt;/p&gt;

&lt;p&gt;So these don't bite you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The card count attributes are bounded at 25, so don't write rules that need numbers above that.&lt;/li&gt;
&lt;li&gt;The rule editor tests a rule against past payments, and a traffic allocation of 0% runs it in shadow mode, reporting what it &lt;em&gt;would&lt;/em&gt; have done. With thin history, a week in shadow mode is a decent sanity check.&lt;/li&gt;
&lt;li&gt;Be careful with &lt;code&gt;:cvc_check:&lt;/code&gt; in your own custom rules. It's a post-authorization attribute, and Stripe notes the cardholder may briefly see an authorization even if the charge ends up blocked. The built-in risk-score CVC rule is the easier option.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fix your own endpoint, because Radar only sees what reaches Stripe
&lt;/h2&gt;

&lt;p&gt;Radar can't stop your server from creating thousands of Checkout Sessions or Customers for a script. That part is on you.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Require login (and ideally a verified email) before you create anything in Stripe.&lt;/strong&gt; Stripe's guide says the easier it is to reach your payment form, the easier card testing gets. Don't create a Stripe Customer at signup "just in case". Create it when a verified user actually goes to pay or start a trial.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CAPTCHA on the endpoint that creates the PaymentIntent, SetupIntent or Checkout Session,&lt;/strong&gt; checked on the server. Stripe's troubleshooting list for a CAPTCHA that isn't working starts with: require it on every request that leads to a card validation or payment, and check it server side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limit that endpoint per IP and per account.&lt;/strong&gt; Stripe's own example is limiting how many new customers one IP can create in a day. Their combined idea is nice too: let the first attempt from an IP through with no friction, then require a CAPTCHA for that IP for the next few hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limit cards per account.&lt;/strong&gt; Real customers add one card, maybe two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Think about a trial without a card.&lt;/strong&gt; If card-required trials are not core to your model, Checkout supports free trials without collecting payment details using &lt;code&gt;payment_method_collection=if_required&lt;/code&gt;, with &lt;code&gt;trial_settings.end_behavior.missing_payment_method&lt;/code&gt; deciding whether to cancel or pause when the trial ends. No card form at signup, nothing to test. It's a conversion tradeoff, your call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep your secret key secret.&lt;/strong&gt; If it leaks, testers can create payments and set up cards directly with it, and none of your front-end defenses matter.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If it's happening right now
&lt;/h2&gt;

&lt;p&gt;Stripe's active card testing checklist is short: identify it, refund the fraudulent payments, add mitigations or move to a recommended integration, then keep monitoring. In practice for a small SaaS:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Close the door first.&lt;/strong&gt; Put the checkout or trial endpoint behind login, turn on the CAPTCHA check, or temporarily switch off card-required trials. Ugly is fine for a day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refund the junk payments that succeeded.&lt;/strong&gt; Stripe recommends refunding suspicious payments that got through, so they don't become early fraud warnings or disputes. If you use manual capture, cancel the uncaptured ones instead of capturing them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't retry the fake cards.&lt;/strong&gt; Stripe calls this out: if your dunning keeps retrying cards attached to fraudulent customers, you're repeating the attack from your side. Cancel the fake subscriptions and clean up those customers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the velocity rules&lt;/strong&gt; above if you have custom rules, in shadow first if you can afford to wait, straight to block if you can't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contact Stripe support&lt;/strong&gt; and tell them it's card testing. They can see things on their side you can't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the 402 graph for a few days&lt;/strong&gt; to make sure it actually stopped and didn't just slow down.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why it's worth an hour before launch
&lt;/h2&gt;

&lt;p&gt;Stripe's guide lists the consequences, and none are about the few dollars of test charges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Disputes and early fraud warnings&lt;/strong&gt; from the payments that went through. At under 100 payments a month, &lt;a href="https://dev.to/posting-dude/under-100-payments-a-month-one-stripe-dispute-is-already-a-1-dispute-rate-5ge4"&gt;one dispute is already a 1% dispute rate&lt;/a&gt;, so a handful of them is a real problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Higher decline rates for your real customers,&lt;/strong&gt; even after the attack stops, because issuers start seeing your business as riskier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extra fees,&lt;/strong&gt; like dispute fees, and authorization fees if you're on a custom pricing plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Card monitoring programs&lt;/strong&gt; if the fraud warnings and disputes pile up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Junk data.&lt;/strong&gt; Fake trials look like growth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And there's the account itself. A small account that suddenly looks risky is how reviews and payout holds start. There's a longer piece on &lt;a href="https://indiehunt.io/studio/how-to-protect-your-indie-saas-from-account-lockouts?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=stripe-card-testing" rel="noopener noreferrer"&gt;keeping your indie SaaS from getting locked out of Stripe and other critical accounts&lt;/a&gt; worth reading before that day.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;[ ] Checkout or Payment Element, not a hand-rolled card form, if you can&lt;/li&gt;
&lt;li&gt;[ ] Stripe.js loaded on every page, and IP, email, name, billing address passed to Stripe&lt;/li&gt;
&lt;li&gt;[ ] "Use Radar on payment methods saved for future use" turned on if trials collect a card&lt;/li&gt;
&lt;li&gt;[ ] Built-in CVC / postal code risk-score rules reviewed&lt;/li&gt;
&lt;li&gt;[ ] No Stripe Customer, PaymentIntent, SetupIntent or Checkout Session created before login + email verification&lt;/li&gt;
&lt;li&gt;[ ] Server-side CAPTCHA and per-IP / per-account rate limit on that endpoint&lt;/li&gt;
&lt;li&gt;[ ] Max cards per account&lt;/li&gt;
&lt;li&gt;[ ] If you're on Radar Plus or Pro: velocity rules in shadow mode, then block&lt;/li&gt;
&lt;li&gt;[ ] A saved filter or alert for failed payments and 402s, so you see a spike the same day&lt;/li&gt;
&lt;li&gt;[ ] Secret key only on the server, never in a repo or front-end bundle&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is security or legal advice, just the Stripe settings I'd check before putting a checkout link in front of a launch crowd.&lt;/p&gt;

&lt;p&gt;One question for people who already run a card-required free trial on Stripe: did you turn on Radar for SetupIntents, and if so, did it block real trial signups or only the junk?&lt;/p&gt;

</description>
      <category>saas</category>
      <category>indiehackers</category>
      <category>stripe</category>
      <category>security</category>
    </item>
    <item>
      <title>When a Customer Switches Plans Mid-Month and You Have Under 50 Subscribers</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Wed, 21 Oct 2026 05:13:06 +0000</pubDate>
      <link>https://dev.to/posting-dude/when-a-customer-switches-plans-mid-month-and-you-have-under-50-subscribers-1jnn</link>
      <guid>https://dev.to/posting-dude/when-a-customer-switches-plans-mid-month-and-you-have-under-50-subscribers-1jnn</guid>
      <description>&lt;p&gt;Say a customer on your $19/month Starter plan emails on the 16th: "can I move to Pro today? I need the export thing." You open Stripe, swap the price on their subscription, and reply "done".&lt;/p&gt;

&lt;p&gt;What did they just pay? Nothing yet, most likely. And on the 1st they'll get an invoice for $64 instead of $49, and you'll get an email asking why.&lt;/p&gt;

&lt;p&gt;That's proration. It's not a bug, it's Stripe doing exactly what the default says. But when you have under 50 subscribers, every one of these plan changes is a support thread with your name on it, so it's worth knowing what each switch actually charges before you click. This is the version I'd want: upgrades, downgrades, monthly to annual, the four traps, and how to check the number before the customer sees it. Not tax or accounting advice.&lt;/p&gt;

&lt;p&gt;All numbers below use a 30-day month to keep the math readable. Stripe prorates to the second, so real invoices will be a few cents off.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default: credit the old plan, charge the new one, bill it later
&lt;/h2&gt;

&lt;p&gt;Stripe's &lt;a href="https://docs.stripe.com/billing/subscriptions/prorations" rel="noopener noreferrer"&gt;proration docs&lt;/a&gt; describe it as two lines. When a plan changes mid-period, the customer gets a credit for the unused time on the old price, and a charge for the remaining time on the new one.&lt;/p&gt;

&lt;p&gt;For the Starter to Pro upgrade on day 15:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line&lt;/th&gt;
&lt;th&gt;Amount&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Unused time on Starter ($19 x 15/30)&lt;/td&gt;
&lt;td&gt;-$9.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remaining time on Pro ($49 x 15/30)&lt;/td&gt;
&lt;td&gt;+$24.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Net proration&lt;/td&gt;
&lt;td&gt;+$15.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The part people miss is when that $15 gets billed. The default &lt;code&gt;proration_behavior&lt;/code&gt; is &lt;code&gt;create_prorations&lt;/code&gt;, which creates those two lines as pending items and puts them on the &lt;strong&gt;next&lt;/strong&gt; invoice. So on the 1st the customer sees $49 for the new month plus the $15 adjustment. $64. Correct, but nobody expects it.&lt;/p&gt;

&lt;p&gt;You have three settings per update:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;create_prorations&lt;/code&gt; (default): calculate the adjustment, bill it on the next renewal.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;always_invoice&lt;/code&gt;: calculate it and invoice it right now. The customer pays $15 today and $49 on the 1st. Much easier to explain.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;none&lt;/code&gt;: no adjustment. They pay the full new price from the next invoice. There's no setting that turns prorations off for a subscription for good, you pass &lt;code&gt;none&lt;/code&gt; on every request that would prorate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For upgrades at this size I'd use &lt;code&gt;always_invoice&lt;/code&gt;. "You paid $15 today for the rest of this month, normal $49 from the 1st" is one sentence in a reply. "Your next invoice includes a proration adjustment" is a follow-up email.&lt;/p&gt;

&lt;h2&gt;
  
  
  Downgrades: the credit doesn't go back to their card
&lt;/h2&gt;

&lt;p&gt;Now flip it. A Pro customer drops to Starter on day 15:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line&lt;/th&gt;
&lt;th&gt;Amount&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Unused time on Pro&lt;/td&gt;
&lt;td&gt;-$24.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remaining time on Starter&lt;/td&gt;
&lt;td&gt;+$9.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Net proration&lt;/td&gt;
&lt;td&gt;-$15.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With the default, that -$15 lands on the next invoice: $19 minus $15, so they pay $4 next month. Stripe's docs are clear that negative prorations aren't automatically refunded. The credit reduces what they owe next, it doesn't go back to the card. If they cancel before the next invoice, you have a credit sitting on a customer who's gone, and you're deciding manually whether to refund it.&lt;/p&gt;

&lt;p&gt;Most tiny SaaS don't need any of that for a downgrade. The cleaner policy is "you keep Pro until the end of the month you paid for, then you're on Starter". No credit, no $4 invoice, nothing to explain.&lt;/p&gt;

&lt;p&gt;In Stripe that's a &lt;a href="https://docs.stripe.com/billing/subscriptions/subscription-schedules" rel="noopener noreferrer"&gt;subscription schedule&lt;/a&gt;, not a normal update. The documented pattern is two calls: create a schedule &lt;code&gt;from_subscription&lt;/code&gt;, which gives you one phase matching the current period, then update it with a second phase on the cheaper price. The second phase starts when the current one ends.&lt;/p&gt;

&lt;p&gt;Two things the docs warn about that are easy to get wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When you update the schedule, pass the whole current phase again (price, quantity, discounts, metadata). Stripe unsets anything you leave out.&lt;/li&gt;
&lt;li&gt;Once a schedule is attached, change the subscription through the Schedules API, not the Subscriptions API. Some direct changes can get overwritten at the next phase transition. Store the schedule ID next to the subscription ID.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you use Customer Portal, there's a "Manage downgrades" setting that does the schedule for you at the end of the billing period. It only works between prices on the same product, and it creates a schedule you then need to respect the same way. I went through the portal settings I'd start with in &lt;a href="https://dev.to/posting-dude/turn-on-stripe-customer-portal-before-you-hit-50-customers-gg6"&gt;turning on Stripe Customer Portal before 50 customers&lt;/a&gt;, the plan-switch part pairs with this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monthly to annual: the one that bills today
&lt;/h2&gt;

&lt;p&gt;This is the switch you actually want people to make, and it behaves differently from the other two. Stripe's &lt;a href="https://docs.stripe.com/billing/subscriptions/change-price" rel="noopener noreferrer"&gt;guide on changing prices&lt;/a&gt; says when the old and new price have the same billing period, the billing dates stay the same. When the period changes, like monthly to yearly, the new price is billed at the new interval starting the day of the change.&lt;/p&gt;

&lt;p&gt;So say a Starter customer on day 10 switches to a $190/year plan. On a classic-mode subscription:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Line&lt;/th&gt;
&lt;th&gt;Amount&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Unused time on monthly Starter ($19 x 20/30)&lt;/td&gt;
&lt;td&gt;-$12.67&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Annual Starter, starting today&lt;/td&gt;
&lt;td&gt;+$190.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Charged today&lt;/td&gt;
&lt;td&gt;$177.33&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And their renewal date moves to today, next year. If you send them a "you're on annual now" note, say that, because the date they're used to just changed.&lt;/p&gt;

&lt;p&gt;If you pass &lt;code&gt;none&lt;/code&gt; here, the API reference says there's no credit for the unused monthly time, but the billing date still resets and they still get charged the full $190 today. So they pay for 20 days twice. Leave proration on for this one.&lt;/p&gt;

&lt;p&gt;There's a catch if your subscriptions are on Stripe's newer billing mode, which I'll get to below: there, the billing cycle anchor is never reset automatically. If you want "annual starts today and charges today", ask for the anchor reset explicitly on the update and preview the result first.&lt;/p&gt;

&lt;p&gt;I'm biased here, I think an annual option belongs on the pricing page way earlier than most founders add it. I made that case in &lt;a href="https://praneetbrar7.medium.com/put-an-annual-plan-on-your-pricing-page-before-you-think-you-need-one-76e450d95607" rel="noopener noreferrer"&gt;this piece on adding an annual plan before you think you need one&lt;/a&gt;. But the switch itself is the part that generates support email, so test it before you promote it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four traps I'd check before the first plan change
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Pass the subscription item ID or you'll bill both plans.&lt;/strong&gt; The change-price docs say this twice for a reason: if you update the subscription with a new price but don't specify which item it replaces, Stripe &lt;em&gt;adds&lt;/em&gt; the new price as a second item. Now the customer is on Starter and Pro at once. Same page: changing the price resets the quantity to 1 unless you pass it again. If you sell seats, a 5-seat team becomes a 1-seat team.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Unpaid invoice plus a plan change can credit time they never paid for.&lt;/strong&gt; Stripe calculates prorations assuming earlier invoices will eventually be paid. If a customer is &lt;code&gt;past_due&lt;/code&gt; and downgrades, the credit for "unused" time on the expensive plan can be for time that was never paid. The docs suggest &lt;code&gt;proration_behavior=none&lt;/code&gt; when the latest invoice is unpaid, and voiding the old invoice if you bill again, so they don't pay twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Charging now doesn't mean the change waits for the money.&lt;/strong&gt; With &lt;code&gt;always_invoice&lt;/code&gt;, if the card fails, the update still goes through and the subscription goes &lt;code&gt;past_due&lt;/code&gt;. So the customer has Pro and you have a failed invoice. If you'd rather the upgrade only happen once it's paid, use &lt;code&gt;payment_behavior=pending_if_incomplete&lt;/code&gt;. Stripe holds the change as a &lt;a href="https://docs.stripe.com/billing/subscriptions/pending-updates" rel="noopener noreferrer"&gt;pending update&lt;/a&gt; and applies it when payment succeeds. It expires after 23 hours (or earlier, at the trial end or period end if those come first), and then the invoice is voided.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Discounts and prorations don't stack the way you'd guess.&lt;/strong&gt; Stripe calculates proration amounts from the discounted price, but the proration lines themselves are marked not discountable. So if someone is on a launch coupon and upgrades, the credit and the charge both already reflect the coupon, and the coupon won't knock anything more off those lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check which billing mode your subscriptions are on
&lt;/h2&gt;

&lt;p&gt;This one is easy to miss. Stripe now has two billing modes, &lt;code&gt;classic&lt;/code&gt; and &lt;code&gt;flexible&lt;/code&gt;, and they prorate differently. Per Stripe's &lt;a href="https://docs.stripe.com/billing/subscriptions/billing-mode/compare" rel="noopener noreferrer"&gt;comparison page&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In classic mode, a credit proration is based on the item's &lt;em&gt;current&lt;/em&gt; price. Their example: upgrade with &lt;code&gt;none&lt;/code&gt;, then downgrade 10 days later, and classic credits time on a price the customer never paid. Flexible credits what was actually billed.&lt;/li&gt;
&lt;li&gt;Classic resets the billing anchor automatically when you switch to a different interval. Flexible never does unless you ask.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which one you get depends on how the subscription was created. For API version &lt;code&gt;2025-09-30.clover&lt;/code&gt; and later the default is flexible. Earlier versions default to classic. Subscriptions made in the Dashboard (and Payment Links and pricing tables made there) use the default set in your billing settings. You can migrate a subscription from classic to flexible, but not back.&lt;/p&gt;

&lt;p&gt;So before you write any plan-change code, open one real subscription and look at &lt;code&gt;billing_mode&lt;/code&gt;. A lot of older proration examples online assume classic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preview the number before the customer sees it
&lt;/h2&gt;

&lt;p&gt;Stripe lets you preview the next invoice with the change applied, without changing anything. Pass a &lt;code&gt;proration_date&lt;/code&gt; so the preview and the real update use the same second, otherwise the amount drifts a little between the two calls.&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;PRORATION_DATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;

curl https://api.stripe.com/v1/invoices/create_preview &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="s2"&gt;:"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;customer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cus_123 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;subscription&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sub_123 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"subscription_details[items][0][id]=si_123"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"subscription_details[items][0][price]=price_pro_monthly"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"subscription_details[proration_date]=&lt;/span&gt;&lt;span class="nv"&gt;$PRORATION_DATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; preview.json

&lt;span class="c"&gt;# just the proration lines, in dollars&lt;/span&gt;
jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.lines.data[]
  | select(.parent.subscription_item_details.proration == true)
  | "\(.amount / 100)\t\(.description)"'&lt;/span&gt; preview.json

jq &lt;span class="s1"&gt;'.amount_due / 100'&lt;/span&gt; preview.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the Starter to Pro example, the first jq prints the -9.5 and 24.5 lines, and the second prints 64, because the preview shows the next invoice under the default behavior (adjustment plus the new month). If you're going to use &lt;code&gt;always_invoice&lt;/code&gt;, the proration lines are the part that gets charged today.&lt;/p&gt;

&lt;p&gt;Then send the real update with the same &lt;code&gt;proration_date&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.stripe.com/v1/subscriptions/sub_123 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="s2"&gt;:"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"items[0][id]=si_123"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"items[0][price]=price_pro_monthly"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;proration_behavior&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;always_invoice &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"proration_date=&lt;/span&gt;&lt;span class="nv"&gt;$PRORATION_DATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run all three switches once in test mode with a test clock, so you've seen each one end to end before a real customer asks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually set up under 50 subscribers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Upgrades: &lt;code&gt;always_invoice&lt;/code&gt;, plus &lt;code&gt;pending_if_incomplete&lt;/code&gt; if you only want to unlock after payment.&lt;/li&gt;
&lt;li&gt;Downgrades: at period end, through a schedule (or the portal's "Manage downgrades").&lt;/li&gt;
&lt;li&gt;Monthly to annual: proration on, explicit anchor reset if you're on flexible, preview first.&lt;/li&gt;
&lt;li&gt;Your app's access follows the webhook, not your "done" reply. Upgrade unlocks when the subscription update lands, downgrade locks when the next phase starts.&lt;/li&gt;
&lt;li&gt;One short confirmation email after every change: old plan, new plan, what they paid today, what the next invoice will be and when. If you don't have one yet, this breakdown of &lt;a href="https://indiehunt.io/studio/how-to-write-billing-emails-that-cut-support-tickets?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=stripe-plan-change-proration" rel="noopener noreferrer"&gt;the billing emails that cut support tickets&lt;/a&gt; covers the plan-change one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's most of it. None of this is hard, it's just that the default does something reasonable that nobody explains to the customer.&lt;/p&gt;

&lt;p&gt;If you handle plan changes on Stripe already: do you prorate downgrades and let the credit show up on the next invoice, or make everyone wait until the end of the period they paid for?&lt;/p&gt;

</description>
      <category>indiehackers</category>
      <category>stripe</category>
      <category>payments</category>
      <category>saas</category>
    </item>
    <item>
      <title>Every IndieHunt launch now has a public /live page: verified upvotes, guest votes shown apart and not counted, outbound clicks, comments, rank this week. Nothing on it is estimated. https://indiehunt.io/project/toolverified/live?utm_source=devto</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Mon, 19 Oct 2026 05:42:17 +0000</pubDate>
      <link>https://dev.to/posting-dude/every-indiehunt-launch-now-has-a-public-live-page-verified-upvotes-guest-votes-shown-apart-and-5dkc</link>
      <guid>https://dev.to/posting-dude/every-indiehunt-launch-now-has-a-public-live-page-verified-upvotes-guest-votes-shown-apart-and-5dkc</guid>
      <description>&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://indiehunt.io/project/toolverified/live?utm_source=devto" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Findiehunt.io%2Fproject%2Ftoolverified%2Fopengraph-image" height="630" class="m-0" width="1200"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://indiehunt.io/project/toolverified/live?utm_source=devto" rel="noopener noreferrer" class="c-link"&gt;
            ToolVerified live launch stats
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            ToolVerified is an artificial intelligence tool. A hand-checked directory of new tools, each reviewed by a person before it publishes Freemium pricing.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Findiehunt.io%2Ffavicon.ico" width="48" height="48"&gt;
          indiehunt.io
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


</description>
    </item>
  </channel>
</rss>
