<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Usama Ejaz]]></title><description><![CDATA[Usama Ejaz]]></description><link>https://usamaejaz.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 22 Oct 2026 08:42:29 GMT</lastBuildDate><atom:link href="https://usamaejaz.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Heading Anchors After a Blog Migration: 12 Headings Through 9 Markdown Renderers]]></title><description><![CDATA[A 301 keeps the #section in a link, but it only lands if the new page has the same heading ID. How 9 renderers slug headings, plus a fix script.]]></description><link>https://usamaejaz.hashnode.dev/heading-anchors-after-a-blog-migration-12-headings-through-9-markdown-renderers</link><guid isPermaLink="true">https://usamaejaz.hashnode.dev/heading-anchors-after-a-blog-migration-12-headings-through-9-markdown-renderers</guid><category><![CDATA[webdev]]></category><category><![CDATA[markdown]]></category><category><![CDATA[SEO]]></category><category><![CDATA[Blogging]]></category><dc:creator><![CDATA[Usama Ejaz]]></dc:creator><pubDate>Thu, 22 Oct 2026 05:48:53 GMT</pubDate><content:encoded><![CDATA[<p>A 301 keeps the <code>#section</code> part of a link. Your new page usually doesn't.</p>
<p>When someone opens <code>/old-post/#step-3</code> and the server answers with a 301 to <code>/new-post/</code>, the browser carries <code>#step-3</code> over to the new address, per <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.2">RFC 9110 section 10.2.2</a>. The reader lands on the right section only if the new page has an element with exactly that ID. The server can't fix it either... the fragment is never sent in the request (<a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-17.11">RFC 9110 section 17.11</a>), so no redirect rule can rename it.</p>
<p>So it comes down to how the old and new platforms turn heading text into an <code>id</code>. While writing migration docs for <a href="https://justblogged.com">JustBlogged</a>, the blog platform I run, I wanted actual answers instead of guesses. On October 22, 2026 I ran the same 12 headings through nine renderers.</p>
<p>Jekyll, Hugo, Astro and pandoc's GFM mode gave identical IDs for all 12. Ghost, Python-Markdown (what MkDocs uses), pandoc's default Markdown, Eleventy's IdAttributePlugin and markdown-it-anchor's defaults each disagreed with them on several.</p>
<h2>What the browser does with #section after a redirect</h2>
<p>I served two test pages from a local Python server, with <code>/old/</code> answering 301 to the new page, and opened <code>/old/#example-1</code> in Chromium 141 through Playwright. The address bar ended on the new URL with <code>#example-1</code> still attached, and the page scrolled to that heading.</p>
<p>With a fragment the new page didn't have, the URL kept it and the page just stayed at the top. No error. That's why broken anchors go unnoticed after a migration: the page loads, the link checker sees a 200, and the reader scrolls around looking for the section.</p>
<p>One browser rule helps when the old IDs were encoded. The HTML standard's <a href="https://html.spec.whatwg.org/multipage/browsing-the-web.html#scrolling-to-a-fragment">fragment-scrolling steps</a> look for an element whose ID equals the fragment as written, and if none exists, try again with the percent-decoded fragment. So <code>#caf%C3%A9-menus</code> finds <code>id="café-menus"</code>. That matters for Ghost, which writes percent-encoded IDs.</p>
<h2>How nine renderers turned the same headings into IDs</h2>
<p>The test post had 12 H2 headings picked to hit the usual differences: punctuation, a straight and a curly apostrophe, accents, an emoji, Japanese text, a spaced hyphen, underscores, a code span and a repeated heading.</p>
<p>Versions: Jekyll 4.4.1 (kramdown 2.5.2 with the GFM parser, Jekyll's default), Hugo 0.168.0 on defaults, Astro's Markdown pipeline (<code>@astrojs/markdown-remark</code> 7.3.4), pandoc 3.1.3 in both <code>markdown</code> and <code>gfm</code> modes, Python-Markdown 3.11.2 with the <code>toc</code> extension, markdown-it 15.0.3 with markdown-it-anchor 10.0.0 on defaults, Eleventy 3.1.7 with IdAttributePlugin on, and Ghost's <code>@tryghost/kg-lexical-html-renderer</code> 1.4.5. The Ghost column comes from that renderer package run directly, not a live Ghost site.</p>
<p>Two headings came out the same everywhere: <code>Using `noindex` on tag pages</code> became <code>using-noindex-on-tag-pages</code>, and the first of two "Example" headings became <code>example</code>. The other ten split like this ("same" means identical to the first column):</p>
<table>
<thead>
<tr>
<th>Heading</th>
<th>Jekyll, Hugo, Astro, pandoc gfm</th>
<th>Ghost</th>
<th>Python-Markdown</th>
<th>Eleventy IdAttributePlugin</th>
<th>markdown-it-anchor default</th>
<th>pandoc markdown</th>
</tr>
</thead>
<tbody><tr>
<td>Set up DNS (A/AAAA records)</td>
<td><code>set-up-dns-aaaaa-records</code></td>
<td>same</td>
<td>same</td>
<td><code>set-up-dns-a-aaaa-records</code></td>
<td><code>set-up-dns-(a%2Faaaa-records)</code></td>
<td>same</td>
</tr>
<tr>
<td>What's new in v2.0?</td>
<td><code>whats-new-in-v20</code></td>
<td>same</td>
<td>same</td>
<td><code>whats-new-in-v2-0</code></td>
<td><code>what's-new-in-v2.0%3F</code></td>
<td><code>whats-new-in-v2.0</code></td>
</tr>
<tr>
<td>Ghost's 404 page (curly apostrophe)</td>
<td><code>ghosts-404-page</code></td>
<td><code>ghost%E2%80%99s-404-page</code></td>
<td>same</td>
<td><code>ghost-s-404-page</code></td>
<td><code>ghost%E2%80%99s-404-page</code></td>
<td>same</td>
</tr>
<tr>
<td>C++ &amp; C#</td>
<td><code>c--c</code></td>
<td><code>c-c</code></td>
<td><code>c-c</code></td>
<td><code>c-and-c</code></td>
<td><code>c%2B%2B-%26-c%23</code></td>
<td><code>c-c</code></td>
</tr>
<tr>
<td>Café menus, naïve UTF-8</td>
<td><code>café-menus-naïve-utf-8</code></td>
<td><code>caf%C3%A9-menus-na%C3%AFve-utf-8</code></td>
<td><code>cafe-menus-naive-utf-8</code></td>
<td><code>cafe-menus-naive-utf-8</code></td>
<td><code>caf%C3%A9-menus%2C-na%C3%AFve-utf-8</code></td>
<td>same</td>
</tr>
<tr>
<td>(rocket emoji) Launch notes</td>
<td><code>-launch-notes</code></td>
<td><code>%F0%9F%9A%80-launch-notes</code></td>
<td><code>launch-notes</code></td>
<td><code>launch-notes</code></td>
<td><code>%F0%9F%9A%80-launch-notes</code></td>
<td><code>launch-notes</code></td>
</tr>
<tr>
<td>日本語の見出し</td>
<td><code>日本語の見出し</code></td>
<td>percent-encoded form of the same text</td>
<td><code>_1</code></td>
<td>empty <code>id=""</code></td>
<td>percent-encoded form of the same text</td>
<td>same</td>
</tr>
<tr>
<td>Example (second time)</td>
<td><code>example-1</code></td>
<td>same</td>
<td><code>example_1</code></td>
<td><code>example-2</code></td>
<td>same</td>
<td>same</td>
</tr>
<tr>
<td>Step 1 - Install</td>
<td><code>step-1---install</code></td>
<td><code>step-1install</code></td>
<td><code>step-1-install</code></td>
<td><code>step-1-install</code></td>
<td>same</td>
<td>same</td>
</tr>
<tr>
<td>snake_case_names</td>
<td><code>snake_case_names</code></td>
<td><code>snakecasenames</code></td>
<td>same</td>
<td><code>snake-case-names</code></td>
<td>same</td>
<td>same</td>
</tr>
</tbody></table>
<p>Eleventy only adds heading IDs if you opt in to its <a href="https://www.11ty.dev/docs/plugins/id-attribute/">IdAttributePlugin</a>, which uses the built-in <a href="https://www.11ty.dev/docs/filters/slugify/">slugify filter</a> and <code>@sindresorhus/slugify</code>. An Eleventy site that wired up markdown-it-anchor itself follows whatever slug function its config passes in, so check yours.</p>
<p>Ghost's rules are short enough to read in its <a href="https://cdn.jsdelivr.net/npm/@tryghost/kg-utils@1.1.8/build/esm/slugify.js">slugify source</a>: remove ASCII punctuation including underscores, replace spaces with hyphens, delete any run of two or more hyphens outright, then percent-encode.</p>
<h2>Which moves keep their anchors</h2>
<p>Jekyll, Hugo and Astro on default settings kept every anchor between each other, because all three follow GitHub's heading rules: <a href="https://jekyllrb.com/docs/configuration/default/">Jekyll's default config</a> sets kramdown's input to GFM, <a href="https://gohugo.io/configuration/markup/">Hugo's</a> <code>autoIDType</code> defaults to <code>github</code>, and <a href="https://docs.astro.build/en/guides/markdown-content/">Astro</a> uses <code>github-slugger</code>. A Jekyll site on kramdown's plain parser, or a Hugo site set to <code>github-ascii</code> or <code>blackfriday</code>, is outside what I tested.</p>
<p>Ghost to one of those is mixed. Plain letters, digits and spaces matched, and so did parentheses, a straight apostrophe and a question mark. Accents and Japanese text also worked in Chromium through the percent-decoding rule: <code>/old/#caf%C3%A9-menus-na%C3%AFve-utf-8</code> landed on <code>id="café-menus-naïve-utf-8"</code>. A curly apostrophe, an emoji, an underscore, a spaced hyphen or <code>&amp;</code> sent the reader to the top of the page.</p>
<p>Python-Markdown and Eleventy's plugin transliterate accents to ASCII, so "Café menus" breaks in either direction between them and a GitHub-style generator. Python-Markdown gave the Japanese heading <code>_1</code>, and Eleventy wrote an empty <code>id=""</code> that no link can reach. Eleventy also numbers a repeated heading from 2, so the second "Example" became <code>example-2</code> where everything except Python-Markdown wrote <code>example-1</code>. markdown-it-anchor's default keeps punctuation and percent-encodes most of it, so almost any heading with punctuation changes when you leave it.</p>
<p>WordPress core doesn't add heading IDs at all. Anchors on a WordPress post came from the heading block's HTML anchor field or a table-of-contents plugin, each with its own format. You can't predict those from the heading text, so read them out of the old pages.</p>
<h2>List the IDs your new pages are missing</h2>
<p>Run this before you change DNS, while the old site still answers. It reads a CSV of <code>old_url,new_url</code> pairs (the redirect map you need anyway), fetches both pages, and prints every <code>id</code> or <code>name</code> on the old page with no match on the new one. A percent-decoded match counts as found, same as the browser. Standard library only.</p>
<pre><code class="language-python"># Usage: python3 anchor_diff.py redirects.csv
# Each CSV row: old_url,new_url
import csv, sys, urllib.request
from html.parser import HTMLParser
from urllib.parse import unquote

class IDs(HTMLParser):
    def __init__(self):
        super().__init__()
        self.ids = set()
    def handle_starttag(self, tag, attrs):
        for name, value in attrs:
            if name in ("id", "name") and value:
                self.ids.add(value)

def ids_at(url):
    req = urllib.request.Request(url, headers={"User-Agent": "anchor-diff"})
    with urllib.request.urlopen(req) as r:
        p = IDs()
        p.feed(r.read().decode("utf-8", "replace"))
        return p.ids

for old, new in csv.reader(open(sys.argv[1])):
    new_ids = ids_at(new)
    reachable = new_ids | {unquote(i) for i in new_ids}
    for i in sorted(ids_at(old)):
        if i not in reachable and unquote(i) not in reachable:
            print(f"{old}#{i}\tmissing on {new}")
</code></pre>
<p>On my Ghost-rendered test page and its Astro-rendered replacement it printed five IDs: the curly apostrophe, the emoji, <code>c-c</code>, <code>snakecasenames</code> and <code>step-1install</code>. The same five fragments that left Chromium at the top of the page. On a real site you'll also get theme IDs like navigation, comment and footnote anchors... skim past the ones nobody links to.</p>
<p>Not every missing ID needs fixing. The ones worth keeping are linked from somewhere: your own posts (search your export for <code>href</code> values containing <code>#</code>), docs or forum threads pointing at a section, anything you shared yourself. A heading nobody linked to can take the new ID.</p>
<h2>Keep the old ID on the new page</h2>
<p>Two ways: set the heading's ID explicitly, or leave the new ID alone and put an empty element with the old ID right before the heading. I built both in Jekyll, Hugo, Astro and pandoc.</p>
<ul>
<li><strong>Jekyll and pandoc</strong> accept an explicit ID after the heading text: <code>## Step 1 - Install {#step-1install}</code>. Both produced <code>&lt;h2 id="step-1install"&gt;</code>, per kramdown's <a href="https://kramdown.gettalong.org/syntax.html#specifying-a-header-id">header ID syntax</a>.</li>
<li><strong>Hugo</strong> accepted the same <code>{#step-1install}</code> syntax with no config change. It dropped raw HTML from Markdown though, because <code>renderer.unsafe</code> defaults to <code>false</code>, so the empty-element alias won't survive unless you change that.</li>
<li><strong>Astro</strong> didn't understand <code>{#...}</code>. It slugged the braces into the ID and produced <code>step-1---install-step-1install</code>. Writing the heading as raw HTML, <code>&lt;h2 id="step-1install"&gt;Step 1 - Install&lt;/h2&gt;</code>, kept the ID, and so did an empty element before a Markdown heading.</li>
<li><strong>Python-Markdown</strong> can take an explicit ID through the attr_list extension, per its <a href="https://python-markdown.github.io/extensions/toc/">toc documentation</a>. I didn't build that case.</li>
</ul>
<p>The empty-element alias lets the heading keep its new clean ID while old links still land:</p>
<pre><code class="language-html">&lt;span id="ghost%E2%80%99s-404-page"&gt;&lt;/span&gt;
## Ghost's 404 page
</code></pre>
<p>Copy the old ID exactly as the script prints it, percent signs included. In Chromium that span caught both <code>#ghost%E2%80%99s-404-page</code> and the typed curly-apostrophe version, because the browser encodes the typed character before matching.</p>
<p>I still haven't tested Jekyll on kramdown's plain parser or Hugo's other <code>autoIDType</code> values, so if you're on one of those, run the script before you trust the table</p>
]]></content:encoded></item><item><title><![CDATA[What a Password-Protected Blog Post Still Leaks: WordPress 7.1.3 and Hugo Tested]]></title><description><![CDATA[A WordPress post password hides the body, not the title, URL, image, feed or sitemap entry. What logged-out visitors see in WordPress 7.1.3 and Hugo.]]></description><link>https://usamaejaz.hashnode.dev/what-a-password-protected-blog-post-still-leaks-wordpress-7-1-3-and-hugo-tested</link><guid isPermaLink="true">https://usamaejaz.hashnode.dev/what-a-password-protected-blog-post-still-leaks-wordpress-7-1-3-and-hugo-tested</guid><category><![CDATA[WordPress]]></category><category><![CDATA[privacy]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Blogging]]></category><dc:creator><![CDATA[Usama Ejaz]]></dc:creator><pubDate>Wed, 21 Oct 2026 05:46:07 GMT</pubDate><content:encoded><![CDATA[<p>A password on a WordPress post hides the body and the excerpt. That's it.</p>
<p>The title, the URL, the date and the featured image are still public, and WordPress keeps listing the post in its sitemap, its RSS feed and its REST API. On a static site it's looser... the host's password covers the page, but the generator already copied the post's text into your feed.</p>
<p><a href="https://justblogged.com">JustBlogged</a>, the blog platform I run, doesn't have per-post passwords, so I wanted to see what the platforms that do actually hide. On October 21, 2026 I installed WordPress 7.1.3 locally with the default Twenty Twenty-Five theme, published one protected post with a featured image, and requested every public address that could mention it from a logged-out terminal. Then I did the same with a Hugo 0.168.0 build.</p>
<h2>What a logged-out visitor got from WordPress</h2>
<p>The post was titled "Client notes Q3". Its body and its hand-written excerpt each had a marker word in them, and it had an uploaded featured image. Neither marker word showed up anywhere. Everything else did:</p>
<table>
<thead>
<tr>
<th>Address</th>
<th>What came back</th>
</tr>
</thead>
<tbody><tr>
<td>The post URL</td>
<td>200, the heading "Protected: Client notes Q3", the password form, and the featured image</td>
</tr>
<tr>
<td>Home page</td>
<td>The post in the latest-posts list, with title and featured image</td>
</tr>
<tr>
<td><code>/feed/</code></td>
<td>An item titled "Protected: Client notes Q3" with link and date, the description "There is no excerpt because this is a protected post.", and the password form as content</td>
</tr>
<tr>
<td><code>/wp-sitemap-posts-post-1.xml</code></td>
<td>The post's URL, next to the public posts</td>
</tr>
<tr>
<td><code>/wp-json/wp/v2/posts/5</code></td>
<td>Title, link, date and <code>featured_media</code> ID, with <code>content</code> and <code>excerpt</code> empty and <code>"protected": true</code></td>
</tr>
<tr>
<td><code>/wp-json/wp/v2/media?parent=5</code></td>
<td>The featured image's record and its direct file URL</td>
</tr>
<tr>
<td>The image file in <code>/wp-content/uploads/</code></td>
<td>200, no password asked</td>
</tr>
<tr>
<td><code>/client-notes/embed/</code></td>
<td>The prefixed title and the protected-excerpt sentence</td>
</tr>
<tr>
<td>Site search for a marker word</td>
<td>No results</td>
</tr>
</tbody></table>
<p>This matches WordPress's own <a href="https://wordpress.org/documentation/article/protect-posts-with-password/">password protection docs</a>: the password replaces the content and excerpt and adds "Protected: " to the title. The same page warns that custom fields aren't covered, so a theme or plugin that prints post meta will print it on a protected post unless it checks <code>post_password_required()</code>.</p>
<p>Search coming back empty is on purpose. For logged-out visitors <code>WP_Query</code> adds a <code>post_password = ''</code> condition to every search.</p>
<p>Two details for readers who do have the password:</p>
<ul>
<li>The protected post was sent with <code>Cache-Control: no-cache, must-revalidate, max-age=0, no-store, private</code>. A public post got no cache header at all. So a page cache that respects headers won't serve one reader's unlocked copy to the next person.</li>
<li>The password lives in a <code>wp-postpass_</code> cookie that lasts 10 days by default, changeable with the <a href="https://developer.wordpress.org/reference/hooks/post_password_expires/">post_password_expires filter</a>. WordPress only remembers one password at a time, so opening two posts with different passwords means typing the first one again.</li>
</ul>
<h2>Getting the post out of the feed, sitemap and listings</h2>
<p>The WordPress docs include a snippet that removes protected posts from every query except their own page. I saved it as a must-use plugin at <code>wp-content/mu-plugins/hide-protected.php</code>:</p>
<pre><code class="language-php">&lt;?php
function exclude_protected( $where ) {
	global $wpdb;
	return $where .= " AND {$wpdb-&gt;posts}.post_password = '' ";
}

function exclude_protected_action( $query ) {
	if ( ! is_single() &amp;&amp; ! is_page() &amp;&amp; ! is_admin() ) {
		add_filter( 'posts_where', 'exclude_protected' );
	}
}
add_action( 'pre_get_posts', 'exclude_protected_action' );
</code></pre>
<p>With it on, the post was gone from the home page, the feed, the sitemap and the REST API's post list. Its own URL still showed the password form.</p>
<p>Two things didn't change. <code>/wp-json/wp/v2/posts/5</code> still returned the title to anyone asking for that ID, and the media endpoint still listed the image with its file URL.</p>
<p>The image is the part no query filter reaches. Files in <code>wp-content/uploads</code> are normally served straight by the web server without WordPress running, so there's no password check, and the default theme draws the featured image on the password page itself. If a picture has to stay private I wouldn't make it the featured image or upload it to a public site's media library at all. Same with the title and slug... if the subject alone gives it away, the title has to be neutral before you publish.</p>
<h2>Static sites: the page is protected, the feed isn't</h2>
<p>A static site has no server code to check a password, so protection comes from the host. Netlify has a shared-password visibility setting on Pro and Enterprise according to its <a href="https://www.netlify.com/knowledge-base/how-to-password-protect-a-netlify-site-or-deploy-previews/">knowledge base</a> (as of October 21, 2026). <a href="https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/self-hosted-public-app/">Cloudflare Access</a> can put a login in front of an app on a domain in your Cloudflare account. On your own server, basic auth does the same.</p>
<p>All of these protect the paths you give them. The generator already wrote the post into files at other paths.</p>
<p>In my Hugo build, with minimal templates and no theme, the default output put the post's text in both <code>/index.xml</code> and <code>/posts/index.xml</code>, and its URL in <code>/sitemap.xml</code>. A password rule on <code>/posts/client-notes/</code> covers none of those.</p>
<p>One build option in front matter took it out of both feeds, the sitemap and the section listing, while the page still rendered:</p>
<pre><code class="language-yaml">---
title: Client notes Q3
build:
  list: never
---
</code></pre>
<p>Hugo's <a href="https://gohugo.io/content-management/build-options/">build options docs</a> say <code>list: never</code> keeps the page out of every page collection, and the built-in feed and sitemap templates read from those collections. A theme can build its feed, related posts or search index its own way though, so I'd rebuild and grep the output folder for a phrase from the post before trusting it.</p>
<p>Pagefind is the one that catches people. It reads the built HTML, not Hugo's collections, so an unlisted page that still renders still gets indexed. Its <a href="https://pagefind.app/docs/indexing/">indexing docs</a> recommend marking the pages you want indexed with <code>data-pagefind-body</code>, which drops every page without it. Other generators have the same problem in whichever plugin writes the feed, sitemap and search index.</p>
<h2>Ghost doesn't do per-post passwords</h2>
<p>Ghost restricts posts by membership instead: public, members only, paid members only, or specific tiers, per its <a href="https://ghost.org/help/protected-content/">protected content guide</a>. Readers get in by signing in. The only shared password is Private Site Mode, which covers the whole site, and Ghost's <a href="https://ghost.org/help/where-can-i-find-my-rss-feed/">RSS help page</a> says the public feed is off while that's on, with a private feed URL under Settings, General, "Make this site private".</p>
<h2>Which one fits</h2>
<p>A WordPress post password is fine when the title and the fact the post exists can be public and only the body matters, like a client update or a family post. With the snippet above it stays out of the feed and sitemap. The featured image still needs to be neutral or gone.</p>
<p>A shared password also gets forwarded along with the link. It keeps search engines and casual visitors out, and tells you nothing about who actually read it.</p>
<p>When the post's existence has to stay hidden too, WordPress's Private status went further in my test: 404 for logged-out visitors, not in the sitemap, 401 from the REST API. But the docs limit private posts to Administrators and Editors, so it's for notes to yourself or a co-editor, not readers. For a group of readers you need a real login (Ghost members, or a host-level rule on a separate path) plus the feed, sitemap and search exclusions above.</p>
<p>Anything that would do real damage if the password leaked I wouldn't put on a public blog's server at all</p>
]]></content:encoded></item><item><title><![CDATA[Edited a Blog Post but Readers Still See the Old Version? How to Find the Cache Holding It]]></title><description><![CDATA[An edited post can be held by the reader's browser, a CDN edge, a page cache, or a copy on another service. Headers from nine blog platforms and a…]]></description><link>https://usamaejaz.hashnode.dev/edited-a-blog-post-but-readers-still-see-the-old-version-how-to-find-the-cache-holding-it</link><guid isPermaLink="true">https://usamaejaz.hashnode.dev/edited-a-blog-post-but-readers-still-see-the-old-version-how-to-find-the-cache-holding-it</guid><category><![CDATA[webdev]]></category><category><![CDATA[Blogging]]></category><category><![CDATA[CDN]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Usama Ejaz]]></dc:creator><pubDate>Tue, 20 Oct 2026 05:50:27 GMT</pubDate><content:encoded><![CDATA[<p>You fix a typo in a published post, reload, and it's fixed. Then someone replies with a screenshot of the old version.</p>
<p>Something between the host and that reader is serving a stored copy. It's one of four places: the reader's browser, the host's CDN edge, a page cache on the host's servers, or a copy kept on some other service entirely (a social preview, a feed reader, Google). The first three show up in the post's response headers, so that's where I start.</p>
<p>On October 20, 2026 I pulled the headers from public blogs on nine platforms. Edge lifetimes went from 25 seconds on Hashnode to 12 hours on Bear. And the usual trick of adding a query string to bust the cache only worked on two of the four CDNs I tried it on.</p>
<h2>One curl command</h2>
<p>Run it against the public URL, from a terminal that isn't logged in to the blog:</p>
<pre><code class="language-bash">curl -s -o /dev/null -D - https://yourblog.com/your-post/ \
  | grep -iE '^(cache-control|age|cf-cache-status|x-vercel-cache|cache-status|x-cache|last-modified):'
</code></pre>
<p>What I look at:</p>
<ul>
<li><code>cache-control: max-age=N</code> lets a browser reuse its copy for N seconds without asking. <code>s-maxage=N</code> does the same for shared caches like a CDN, and <a href="https://www.rfc-editor.org/rfc/rfc9111#name-s-maxage">RFC 9111</a> says it overrides <code>max-age</code> there.</li>
<li><code>no-cache</code> or <code>max-age=0</code> means a stored copy has to be revalidated before reuse. It does not mean "don't store it", which <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching">MDN's caching guide</a> is clear about.</li>
<li><code>age</code> is how many seconds the copy has been sitting in a shared cache. <code>age: 5961</code> is an edge copy about 100 minutes old.</li>
<li>The cache-status header tells you if the edge answered from storage. The name depends on the CDN: <code>cf-cache-status</code> on Cloudflare, <code>x-vercel-cache</code> on Vercel, <code>cache-status</code> on Netlify, <code>x-cache</code> on Fastly. <code>HIT</code> means a stored copy.</li>
</ul>
<h2>What nine platforms sent</h2>
<p>One or two requests per platform, to a real site on it. It's a snapshot... hosts change these, and individual sites add their own rules. Medium gave me a 403, so it's not here.</p>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Site checked</th>
<th>Cache-Control on the post HTML</th>
<th>Edge signal</th>
<th>How long an old copy can live</th>
</tr>
</thead>
<tbody><tr>
<td>GitHub Pages</td>
<td>karpathy.github.io, jekyllrb.com</td>
<td><code>max-age=600</code></td>
<td><code>x-cache</code> via Fastly (Varnish)</td>
<td>10 minutes in a browser that already loaded it</td>
</tr>
<tr>
<td>Netlify</td>
<td>docs.netlify.com</td>
<td><code>public, max-age=0, must-revalidate</code></td>
<td><code>cache-status: "Netlify Edge"</code></td>
<td>Until the next deploy, which <a href="https://www.netlify.com/knowledge-base/how-to-control-cdn-caching-on-netlify/">Netlify says</a> clears the CDN cache</td>
</tr>
<tr>
<td>Vercel</td>
<td>nextjs.org/blog</td>
<td><code>public, max-age=0, must-revalidate</code></td>
<td><code>x-vercel-cache: HIT</code>, <code>age: 7426</code></td>
<td>Until the next deployment, which <a href="https://vercel.com/docs/cdn-cache/purge">gets its own cache key</a></td>
</tr>
<tr>
<td>Hashnode</td>
<td>townhall.hashnode.com</td>
<td><code>public, s-maxage=25</code></td>
<td><code>x-vercel-cache: MISS</code></td>
<td>25 seconds at the edge</td>
</tr>
<tr>
<td>Ghost(Pro)</td>
<td>404media.co</td>
<td><code>public, max-age=0</code></td>
<td><code>x-cache: MISS, HIT, HIT</code>, <code>age: 8531</code></td>
<td>Browsers revalidate every time; the edge copy depends on Ghost(Pro) purging it</td>
</tr>
<tr>
<td>WordPress.com</td>
<td>wordpress.com/blog</td>
<td><code>max-age=137, must-revalidate</code> (156 on an earlier request)</td>
<td><code>x-nananana: Batcache-Hit</code></td>
<td>About two and a half minutes in a browser</td>
</tr>
<tr>
<td>Substack</td>
<td>on.substack.com</td>
<td><code>no-cache</code></td>
<td><code>cf-cache-status: HIT</code>, <code>age: 1470</code></td>
<td>Edge copy kept despite <code>no-cache</code>; lifetime not exposed</td>
</tr>
<tr>
<td>Bear</td>
<td>herman.bearblog.dev</td>
<td><code>public, s-maxage=43200, max-age=0</code></td>
<td><code>cf-cache-status: HIT</code>, <code>age: 5842</code></td>
<td>Up to 12 hours at the edge unless Bear purges on edit</td>
</tr>
</tbody></table>
<p>I couldn't find current docs on when Ghost(Pro) or Bear purge the edge after an edit. The closest thing is a 2019 reply on the <a href="https://forum.ghost.org/t/how-is-x-cache-invalidate-used/8117">Ghost forum</a> saying their custom <code>X-Cache-Invalidate</code> header is used on Ghost(Pro)'s caching layer. So the header test on your own post is the only reliable answer I have.</p>
<h2>Step 1: rule out the browser</h2>
<p>Only GitHub Pages and WordPress.com in that table give browsers a positive <code>max-age</code>. Inside that window, a browser that already has the page can show its stored copy when you click back to the post.</p>
<p>A normal reload sends <code>Cache-Control: max-age=0</code> with the stored validators, so the browser checks with the server first. A force reload (Ctrl+Shift+R, Cmd+Shift+R on a Mac) sends <code>no-cache</code> and skips the stored copy. If either one shows the edit, it was the browser, and everyone else's browser catches up within the <code>max-age</code>. That's 10 minutes at most on GitHub Pages.</p>
<h2>Step 2: check the edge copy's age</h2>
<p>If a force reload still shows the old text, and the response has a <code>HIT</code> with an <code>age</code> longer than the time since your edit, the edge copy was stored before you saved. How long it stays is <code>s-maxage</code> or whatever rules the CDN has. On Bear that's 43,200 seconds.</p>
<p>Substack's row is the odd one. <a href="https://developers.cloudflare.com/cache/concepts/default-cache-behavior/">Cloudflare's docs</a> say it doesn't cache HTML by default and skips responses marked <code>no-cache</code>. So a <code>HIT</code> on <code>no-cache</code> HTML means Substack set its own cache rule, with a lifetime the response doesn't tell you.</p>
<p>To confirm the edge is the problem, request the post with a throwaway query string like <code>?nocache=21404</code>. The result depended on the CDN:</p>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Plain URL</th>
<th>With a random query string</th>
</tr>
</thead>
<tbody><tr>
<td>Bear</td>
<td><code>HIT</code>, <code>age: 5961</code></td>
<td><code>MISS</code></td>
</tr>
<tr>
<td>Substack</td>
<td><code>HIT</code>, <code>age: 1600</code></td>
<td><code>MISS</code></td>
</tr>
<tr>
<td>GitHub Pages</td>
<td><code>HIT</code></td>
<td><code>HIT</code></td>
</tr>
<tr>
<td>Ghost(Pro)</td>
<td><code>HIT</code>, <code>age: 8531</code></td>
<td><code>HIT</code>, <code>age: 8651</code></td>
</tr>
</tbody></table>
<p>On Bear and Substack the query string went to the origin. If that version has your edit and the plain URL doesn't, the edge copy is stale and gets replaced when it expires or the host purges it.</p>
<p>On GitHub Pages and Ghost(Pro) it still came back as a hit, and on Ghost(Pro) the <code>age</code> kept counting up. The cache was ignoring the query string. On those hosts this test can't tell the edge from the origin, which I think a lot of "just add ?v=2" advice misses.</p>
<p>If you put your own Cloudflare proxy in front of a hosted blog, you now have two edge caches with separate rules stacked on each other. Check both.</p>
<h2>Step 3: check logged out</h2>
<p>Page caches often treat signed-in visitors differently. The <a href="https://github.com/Automattic/batcache">Batcache readme</a>, which is the cache behind <code>Batcache-Hit</code> on WordPress.com, says it works "by serving old pages to new users," and that once visitors "have logged in or left a comment they will always get fresh pages."</p>
<p>So if you're signed in to your own blog, you see the edit and logged-out readers get the stored page. A private window or the curl above carries none of your cookies.</p>
<h2>Step 4: the copies that aren't on your host</h2>
<p>If the post URL serves the new version and someone still sees the old one, the copy lives on another service, and each one refreshes on its own schedule:</p>
<ul>
<li>Link previews on social networks and chat apps keep the title, description and image they fetched first.</li>
<li>Feed readers only see the edit after they poll your feed again, which can be hours for a small blog.</li>
<li>Google's title and snippet come from its last crawl. Search Console's <a href="https://support.google.com/webmasters/answer/9012289">URL Inspection tool</a> lets you run a live test and request indexing. Google says that "typically takes only a day or so" but can take longer, and requests have a daily limit.</li>
</ul>
<p>I build <a href="https://justblogged.com">JustBlogged</a>, and when I ran the same curl on its post pages they came back with no <code>Cache-Control</code>, no <code>age</code>, no cache-status header and no <code>Last-Modified</code>. Nothing tells a browser or CDN to keep the page, so when someone there reports an old version it's always step 4. The flip side is every request goes to the origin, and I'm still not sure that's the right default</p>
]]></content:encoded></item><item><title><![CDATA[Why New Blog Posts Reach Feed Readers Hours Late, and Which Platforms Send a WebSub Hub]]></title><description><![CDATA[Feed readers poll on a schedule, so a small blog can wait hours. Polling rules for Feedly, Inoreader and Miniflux, which of ten platforms declare a WebSub…]]></description><link>https://usamaejaz.hashnode.dev/why-new-blog-posts-reach-feed-readers-hours-late-and-which-platforms-send-a-websub-hub</link><guid isPermaLink="true">https://usamaejaz.hashnode.dev/why-new-blog-posts-reach-feed-readers-hours-late-and-which-platforms-send-a-websub-hub</guid><category><![CDATA[rss]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Blogging]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Usama Ejaz]]></dc:creator><pubDate>Mon, 19 Oct 2026 05:48:54 GMT</pubDate><content:encoded><![CDATA[<p>A new post shows up in a feed reader only when the reader next fetches your feed. Most readers fetch on a schedule, and for a small blog that schedule is slow. Feedly's free plan checks a feed anywhere from every 30 minutes to once a day.</p>
<p>The way around it is a WebSub hub. Your feed names a hub, your platform pings it when you publish, and readers subscribed to the hub get the post when the hub pushes it. On October 19, 2026 I fetched one feed from each of ten blogging platforms to see which ones declare a hub. Three did.</p>
<h2>How often readers actually check</h2>
<p>These come from each reader's own docs as of October 19, 2026.</p>
<table>
<thead>
<tr>
<th>Reader</th>
<th>How often it polls</th>
<th>Source</th>
</tr>
</thead>
<tbody><tr>
<td>Feedly Basic</td>
<td>30 minutes to 1 day, depending on how popular the site is and how often it publishes</td>
<td><a href="https://docs.feedly.com/article/212-how-often-does-feedly-update">Feedly help</a></td>
</tr>
<tr>
<td>Feedly Pro</td>
<td>15 minutes to 1 hour</td>
<td>same</td>
</tr>
<tr>
<td>Feedly Pro+ and Enterprise</td>
<td>As often as every 7 minutes</td>
<td>same</td>
</tr>
<tr>
<td>Inoreader</td>
<td>Active feeds every 30 minutes to a few hours; few subscribers at most hourly; quiet for a month about daily</td>
<td><a href="https://www.inoreader.com/feed-fetcher">Inoreader feed fetcher</a></td>
</tr>
<tr>
<td>Miniflux (self-hosted)</td>
<td>Round-robin with a 60-minute minimum by default; the optional <code>entry_frequency</code> scheduler adapts from 5 minutes to 24 hours based on the last week</td>
<td><a href="https://miniflux.app/docs/configuration.html">Miniflux configuration</a></td>
</tr>
</tbody></table>
<p>The popularity factor is what hurts a personal blog. A handful of subscribers on free accounts puts you at the slow end of every schedule, and publishing once a week makes you look like a feed that rarely changes.</p>
<p>Both Feedly and Inoreader say what skips the schedule. Feedly says sites using "the PuSH protocol" show up "in near real-time." Inoreader says that when it finds a hub it "receives new items as soon as you publish them" and falls back to polling every few hours.</p>
<h2>What a hub changes</h2>
<p><a href="https://www.w3.org/TR/websub/">WebSub</a> is the W3C Recommendation that grew out of PubSubHubbub, the "PuSH" Feedly mentions. Three parties:</p>
<ul>
<li>Your feed advertises a hub with <code>rel="hub"</code> and its own address with <code>rel="self"</code>.</li>
<li>A reader that supports WebSub subscribes to the hub for that feed.</li>
<li>When you publish, your platform tells the hub, and the hub fetches the feed and pushes it to every subscriber.</li>
</ul>
<p>The spec leaves one step open: "The specific mechanism for the publisher to inform the hub is left unspecified." In practice the public hubs accept the same thing, a form POST with <code>hub.mode=publish</code> and <code>hub.url</code> set to your feed. That's documented on <a href="https://pubsubhubbub.appspot.com/">Google's public hub</a>, which anyone can use without an account.</p>
<p>It doesn't help readers that never look for a hub, and it doesn't stop polling. It removes the wait for the readers that support it.</p>
<h2>Which platforms declare one</h2>
<p>One public feed per platform. I looked for <code>rel="hub"</code> in the XML and in response headers. I also sent each response's <code>ETag</code> and <code>Last-Modified</code> back as <code>If-None-Match</code> and <code>If-Modified-Since</code>, which is what Inoreader says its fetcher does, to see if the server answers an unchanged feed with <code>304 Not Modified</code>. A 304 doesn't speed up delivery, but without it every poll downloads the whole file.</p>
<table>
<thead>
<tr>
<th>Platform (feed checked)</th>
<th>Hub</th>
<th>304 on an unchanged feed</th>
</tr>
</thead>
<tbody><tr>
<td>Medium (<code>medium.com/feed/@ev</code>)</td>
<td>Yes, <code>http://medium.superfeedr.com</code></td>
<td>No validators sent</td>
</tr>
<tr>
<td>WordPress.com (<code>wordpress.com/blog/feed/</code>)</td>
<td>Yes, <code>https://wordpress.com/blog/?pushpress=hub</code></td>
<td>Yes</td>
</tr>
<tr>
<td>Blogger (<code>blog.chromium.org/feeds/posts/default</code>)</td>
<td>Yes, <code>http://pubsubhubbub.appspot.com/</code></td>
<td>No, sent <code>Last-Modified</code> but answered the replay with 200</td>
</tr>
<tr>
<td>Self-hosted WordPress 7.2 alpha (<code>wordpress.org/news/feed/</code>)</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>Ghost 6.71 (<code>404media.co/rss/</code>)</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>Substack (<code>lennysnewsletter.com/feed</code>)</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>Bear (<code>herman.bearblog.dev/feed/</code>)</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>DEV (<code>dev.to/feed/ben</code>)</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>Hashnode (<code>townhall.hashnode.com/rss.xml</code>)</td>
<td>No</td>
<td>No validators sent</td>
</tr>
<tr>
<td>JustBlogged (<code>justblogged.com/blog/rss.xml</code>)</td>
<td>No</td>
<td>No validators sent</td>
</tr>
</tbody></table>
<p>One feed per platform is a sample. A self-hosted WordPress or Ghost site can behave differently because of plugins, themes or the cache in front of it. Several Blogger blogs I tried redirected their feed to FeedBurner, which serves its own copy, so check the URL your readers are actually subscribed to.</p>
<p>The last row is mine. I build <a href="https://justblogged.com">JustBlogged</a>, and its own blog feed carries every full post, 594,833 bytes this morning, with no hub, no <code>ETag</code> and no <code>Last-Modified</code>. So readers of that blog wait for their reader's schedule, and every poll downloads the whole file.</p>
<h2>Check your own feed</h2>
<p>First, does it declare a hub? Either as a link in the XML or as an HTTP <code>Link</code> header, which the spec says subscribers check first.</p>
<pre><code class="language-bash">FEED=https://example.com/feed.xml
curl -sSL "$FEED" | grep -io '&lt;[a-z:]*link[^&gt;]*rel=.hub.[^&gt;]*&gt;'
curl -sSIL "$FEED" | grep -i '^link:.*hub'
</code></pre>
<p>Second, does it answer 304 when nothing changed? This sends back both validators the way a reader would:</p>
<pre><code class="language-bash">H=$(curl -sS -D - -o /dev/null "$FEED")
ETAG=$(echo "$H" | grep -i '^etag:' | cut -d' ' -f2- | tr -d '\r')
LM=$(echo "$H" | grep -i '^last-modified:' | cut -d' ' -f2- | tr -d '\r')
curl -sS -o /dev/null -w '%{http_code}\n' \
  -H "If-None-Match: $ETAG" -H "If-Modified-Since: $LM" "$FEED"
</code></pre>
<p>Send both headers. WordPress.com answered 304 to <code>If-Modified-Since</code> but 200 to its own <code>ETag</code> alone, so a one-header test can report a failure real readers never hit.</p>
<p>While the headers are open, look at <code>cache-control</code> and <code>age</code>. The Bear feed came back with <code>s-maxage=43200</code> and a Cloudflare cache hit, which lets the edge keep a copy for up to 12 hours. I didn't check whether Bear purges it on publish. On any host, if the feed is cached at a CDN and not purged on publish, the new post isn't in the feed readers fetch, and no hub fixes that. Fetch your feed right after publishing and confirm the item is there.</p>
<h2>Adding a hub yourself</h2>
<p>Self-hosted WordPress: the <a href="https://wordpress.org/plugins/pubsubhubbub/">WebSub plugin</a> (4.0.1, 100,000+ active installs as of October 2026) adds hub links to your feeds and pings hubs on publish and update. It uses Google's hub, Superfeedr and WebSubHub by default.</p>
<p>Static site generator: two edits. Add the hub link next to <code>rel="self"</code> in your RSS channel, with the Atom namespace on <code>&lt;rss&gt;</code>:</p>
<pre><code class="language-xml">&lt;rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"&gt;
  &lt;channel&gt;
    &lt;atom:link rel="self" type="application/rss+xml" href="https://example.com/feed.xml"/&gt;
    &lt;atom:link rel="hub" href="https://pubsubhubbub.appspot.com/"/&gt;
    ...
</code></pre>
<p>Then ping at the end of your deploy, after the new feed is live:</p>
<pre><code class="language-bash">curl -sS -o /dev/null -w '%{http_code}\n' -X POST https://pubsubhubbub.appspot.com/ \
  -d hub.mode=publish \
  --data-urlencode hub.url=https://example.com/feed.xml
</code></pre>
<p>A <code>204</code> means the hub accepted the ping, not that anyone got notified. I sent that request for a feed with no hub link at all and Google's hub still answered 204. Delivery only happens for readers that found the hub in your feed and subscribed, so the link has to be live first. And <code>rel="self"</code> has to be the exact URL subscribers use, because that's the topic the hub tracks.</p>
<p>On a hosted platform without a hub there's no setting to flip. Your subscribers' readers poll on their own schedule</p>
]]></content:encoded></item><item><title><![CDATA[Leaving WordPress With AMP: Find Every /amp/ and ?amp=1 URL Before the Old Site Goes Offline]]></title><description><![CDATA[Which AMP URL formats the two main WordPress AMP plugins create, a script to list every AMP URL from your sitemap, and the redirects Google asks for after…]]></description><link>https://usamaejaz.hashnode.dev/leaving-wordpress-with-amp-find-every-amp-and-amp-1-url-before-the-old-site-goes-offline</link><guid isPermaLink="true">https://usamaejaz.hashnode.dev/leaving-wordpress-with-amp-find-every-amp-and-amp-1-url-before-the-old-site-goes-offline</guid><category><![CDATA[WordPress]]></category><category><![CDATA[SEO]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Blogging]]></category><dc:creator><![CDATA[Usama Ejaz]]></dc:creator><pubDate>Mon, 19 Oct 2026 05:48:39 GMT</pubDate><content:encoded><![CDATA[<p>If your WordPress blog ever ran an AMP plugin, every post had a second address. Usually <code>/post-name/amp/</code> or <code>/post-name/?amp=1</code>. Those URLs are still in Google's index and in old shares, and once you move to a host without AMP the <code>/amp/</code> ones start returning 404.</p>
<p>Google's steps for <a href="https://developers.google.com/search/docs/crawling-indexing/amp/remove-amp">removing AMP pages while keeping the canonical page</a> are short: take the <code>rel="amphtml"</code> link off the canonical page, and answer each AMP URL with a 301 or 302 to that canonical page.</p>
<p>The hard part is knowing which AMP URLs exist. I read the source of the two main AMP plugins to find out, and wrote a small script to list them. Everything below was checked on October 18, 2026.</p>
<h2>Which format your site used</h2>
<p>It depends on the plugin and one setting. I downloaded the current releases from WordPress.org and read the code that builds each URL. Examples assume <code>/%postname%/</code> permalinks and AMP enabled for that content type.</p>
<table>
<thead>
<tr>
<th>Plugin and setting</th>
<th>Post</th>
<th>Page</th>
</tr>
</thead>
<tbody><tr>
<td>AMP 2.5.5, Query parameter (default for new installs)</td>
<td><code>/post-name/?amp=1</code></td>
<td><code>/about/?amp=1</code></td>
</tr>
<tr>
<td>AMP 2.5.5, Path suffix</td>
<td><code>/post-name/amp/</code></td>
<td><code>/about/amp/</code></td>
</tr>
<tr>
<td>AMP 2.5.5, Legacy transitional</td>
<td><code>/post-name/?amp</code></td>
<td><code>/about/?amp</code></td>
</tr>
<tr>
<td>AMP 2.5.5, Legacy reader</td>
<td><code>/post-name/amp/</code></td>
<td><code>/about/?amp</code></td>
</tr>
<tr>
<td>AMP for WP 1.1.19, default with pretty permalinks</td>
<td><code>/post-name/amp/</code></td>
<td><code>/about/amp/</code></td>
</tr>
<tr>
<td>AMP for WP 1.1.19, "Change End Point to ?amp" on</td>
<td><code>/post-name/?amp</code></td>
<td><code>/about/?amp</code></td>
</tr>
</tbody></table>
<p>The official <a href="https://wordpress.org/plugins/amp/">AMP plugin</a> lists 300,000+ active installs and was last updated in April 2025. <a href="https://wordpress.org/plugins/accelerated-mobile-pages/">AMP for WP</a> lists 70,000+ and was updated on October 13, 2026.</p>
<p>Legacy reader is the one that surprised me. It uses <code>/amp/</code> for posts but falls back to <code>?amp</code> for pages, attachments, and any URL that already has a query string... so one site can carry both shapes. If you switched the setting at some point, both shapes may also be indexed from different years.</p>
<p>Fastest way to tell: open any post's HTML source on the old site and search for <code>amphtml</code>. The link it prints is the exact AMP URL for that page.</p>
<h2>List every AMP URL while WordPress is still up</h2>
<p>The <code>amphtml</code> link is the best inventory you'll get, and it only exists while WordPress is serving pages. This reads one sitemap, fetches each URL, and writes the AMP address next to the canonical one:</p>
<pre><code class="language-bash">#!/usr/bin/env bash
# Usage: ./amp-map.sh https://old-blog.example.com/wp-sitemap-posts-post-1.xml &gt; amp-map.csv
curl -s "$1" | grep -o '&lt;loc&gt;[^&lt;]*' | sed 's/&lt;loc&gt;//' | while read -r page; do
  amp=$(curl -s "$page" | grep -o '&lt;link[^&gt;]*rel="amphtml"[^&gt;]*&gt;' \
    | sed -E 's/.*href="([^"]+)".*/\1/' | head -1)
  [ -n "$amp" ] &amp;&amp; echo "$amp,$page"
done
</code></pre>
<p>I ran it against five posts on ampforwp.com, the AMP for WP plugin's own site, and every row came back the same shape, e.g. <code>https://ampforwp.com/the-change/amp/,https://ampforwp.com/the-change/</code>.</p>
<p>Run it once per post sitemap. WordPress core lists them at <code>/wp-sitemap.xml</code>, Yoast at <code>/sitemap_index.xml</code>. If AMP was on for pages, categories or the home page, run those sitemaps too.</p>
<p>It only sees pages that print the link today. For posts where AMP was turned off earlier but the AMP URL is still indexed, the <a href="https://support.google.com/webmasters/answer/7450883">AMP status report in Search Console</a> lists the AMP URLs Google knows about.</p>
<h2>Optional: let the plugin redirect before you move</h2>
<p>If WordPress stays up for a few weeks before the DNS change, the official AMP plugin can do Google's two steps for you. In its code, when a URL is requested with the AMP endpoint but AMP isn't available for that content, it redirects to the non-AMP URL and prints an HTML comment in place of the <code>amphtml</code> link. So you leave the plugin active, turn AMP off for your content types under Supported Templates, and the old AMP URLs start redirecting while the posts are still on WordPress.</p>
<p>Two things to know when you test it:</p>
<ul>
<li>It sends a 302 to anyone with <code>manage_options</code> and a 301 to everyone else. Test logged out, or with curl.</li>
<li>A path-suffix URL can take two hops. With AMP off for posts I saw <code>/post-name/amp/</code> 301 to <code>/post-name/?amp=1</code>, which then 301'd to the post. Crawlers follow both, but the redirects you add on the new host should go straight to the final URL.</li>
</ul>
<p>I didn't check whether AMP for WP does the same.</p>
<h2>Redirect the /amp/ paths on the new host</h2>
<p>After the cutover, WordPress isn't answering anymore, so the redirects have to live wherever the posts went. The two shapes behave very differently there.</p>
<p>The query forms (<code>?amp=1</code>, <code>?amp</code>) often just load the normal post, because many hosts ignore query parameters they don't use. That's what I got testing on <a href="https://justblogged.com">JustBlogged</a>, the blog host I run: 200, the normal post, canonical pointing at the clean URL. Google asks for a redirect, so this isn't an exact match, but I'm fine with it. The page isn't AMP, has no <code>amphtml</code> link, and its canonical names the clean URL, which is the same signal Google uses to <a href="https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls">consolidate duplicate URLs</a> like tracking-parameter variants.</p>
<p>The path forms (<code>/post-name/amp/</code>, <code>/post-name/amp</code>) are the real problem. They 404, and a 404 drops whatever links and indexing those URLs had. Each one needs a 301.</p>
<p>Point the redirect at the post's new path, not its old WordPress URL. If the old permalink was <code>/2023/05/post-name/</code> and the post now lives at <code>/post-name</code>, the redirect is <code>/2023/05/post-name/amp/</code> to <code>/post-name</code>. One hop instead of two.</p>
<p>Google warns against one shortcut: emptying the AMP file instead of redirecting it. An empty document is invalid AMP, and Google keeps serving the oldest valid version it has.</p>
<h2>Check every AMP URL after DNS moves</h2>
<pre><code class="language-bash">cut -d, -f1 amp-map.csv | while read -r u; do
  curl -s -o /dev/null -w "%{http_code} $u -&gt; %{redirect_url}\n" "$u"
done
</code></pre>
<p>Every path-form row should be a 301 to the new post URL, and that URL should be 200. A 404 is a missing or mistyped redirect. A redirect to the old WordPress URL is a chain you can shorten.</p>
<p>After that, the AMP status report's count of indexed AMP pages should drop as Google recrawls them, and the redirected AMP URLs should show up under "Page with redirect" in the Page indexing report</p>
]]></content:encoded></item><item><title><![CDATA[Search Console's "Why Pages Aren't Indexed" on a Blog: Which Rows Actually Need Work]]></title><description><![CDATA[Most rows in Search Console's Page indexing report are a blog working as intended. How I sort them, which reasons hold real posts, and curl checks to…]]></description><link>https://usamaejaz.hashnode.dev/search-console-s-why-pages-aren-t-indexed-on-a-blog-which-rows-actually-need-work</link><guid isPermaLink="true">https://usamaejaz.hashnode.dev/search-console-s-why-pages-aren-t-indexed-on-a-blog-which-rows-actually-need-work</guid><category><![CDATA[SEO]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Blogging]]></category><category><![CDATA[Google]]></category><dc:creator><![CDATA[Usama Ejaz]]></dc:creator><pubDate>Mon, 19 Oct 2026 05:48:24 GMT</pubDate><content:encoded><![CDATA[<p>The first time I opened the Page indexing report for a blog I run, the "Why pages aren't indexed" table had more URLs in it than the blog had posts. That looked bad. It mostly wasn't.</p>
<p>An <code>http://</code> address that redirects, a <code>?utm_source=</code> link whose canonical points at the clean URL, an RSS feed sent with a noindex header... all of those land in that table, and none of them should be in search results anyway. Google's <a href="https://support.google.com/webmasters/answer/7440203">Page indexing report documentation</a> says the same: most sites have some unindexed pages, and that's fine as long as you understand the reason and your important pages are indexed.</p>
<p>The rows that need work are the ones holding a URL you actually want in search. On a blog that almost always means a post.</p>
<h2>Sort by URL first, reason second</h2>
<p>Open Indexing, then Pages, and click a row in "Why pages aren't indexed". The details page lists example URLs for that reason, capped at 1,000. For each one I ask a single question: would I want this exact URL as a search result?</p>
<p>If not, the reason Google gives barely matters. If yes, open it in URL Inspection, which shows the live status, the canonical you declared, and the canonical Google picked.</p>
<p>I do it in that order because the reason names are easy to misread. "Duplicate without user-selected canonical" sounds like a fault, but Google calls it working as intended when the URL it chose is the right one. "Crawled - currently not indexed" sounds final, but Google says it may index the page later and that you don't need to resubmit.</p>
<h2>What each reason usually holds on a blog</h2>
<p>Reason names are the ones in the report as of October 2026. The middle column is my summary of where these come from on blogs. The last column follows Google's documentation for each reason.</p>
<table>
<thead>
<tr>
<th>Reason</th>
<th>Typical blog URLs</th>
<th>Act?</th>
</tr>
</thead>
<tbody><tr>
<td>Page with redirect</td>
<td><code>http://</code> and <code>www</code> variants, old slugs, the platform subdomain you used before a custom domain</td>
<td>No, unless a current post URL is redirecting. Check the target is indexed.</td>
</tr>
<tr>
<td>Alternate page with proper canonical tag</td>
<td>Tracking parameters, trailing-slash and case variants that canonicalize to the clean URL</td>
<td>No. Google says no action is needed.</td>
</tr>
<tr>
<td>Duplicate without user-selected canonical</td>
<td>Duplicate URLs that declare no canonical</td>
<td>Only if Google picked the wrong URL. Then declare the canonical.</td>
</tr>
<tr>
<td>Duplicate, Google chose different canonical than user</td>
<td>A canonical pointing at a page with different content, near-identical posts, a cross-posted copy Google treats as the original</td>
<td>Yes. Compare both canonicals in URL Inspection.</td>
</tr>
<tr>
<td>URL marked 'noindex'</td>
<td>Feeds, search results, tag pages you chose to hide, drafts and previews</td>
<td>Only if a post is in the list.</td>
</tr>
<tr>
<td>URL blocked by robots.txt</td>
<td>Admin paths, internal search</td>
<td>Only if a post is in the list. robots.txt doesn't keep a URL out of search; noindex does.</td>
</tr>
<tr>
<td>Not found (404)</td>
<td>Deleted posts, mistyped inbound links, old URLs from a migration</td>
<td>301 it if the content moved. A removed post with no replacement can stay 404.</td>
</tr>
<tr>
<td>Soft 404</td>
<td>Empty tag pages, archive pages past the last post, an error page served with 200</td>
<td>Yes. Return a real 404 or put real content there.</td>
</tr>
<tr>
<td>Server error (5xx), Redirect error</td>
<td>Outages, redirect loops, chains from more than one migration</td>
<td>Yes, before anything else.</td>
</tr>
<tr>
<td>Crawled - currently not indexed</td>
<td>Thin tag and archive pages, sometimes posts</td>
<td>Only for posts you care about.</td>
</tr>
<tr>
<td>Discovered - currently not indexed</td>
<td>New posts, deep archive pages</td>
<td>Usually wait. Google gives no specific fix.</td>
</tr>
</tbody></table>
<h2>Predict the row before Google recrawls</h2>
<p>You don't have to wait for a recrawl to know where a URL will land. The status code, the <code>Location</code> header, <code>X-Robots-Tag</code>, the canonical link and the robots meta tag decide most of it. This prints all five for one URL:</p>
<pre><code class="language-shell">curl -sS -D - -o page.html "https://example.com/blog/my-post?utm_source=x" \
  | grep -iE '^(HTTP|location|x-robots-tag)'
grep -oiE '&lt;link[^&gt;]*rel="?canonical[^&gt;]*&gt;|&lt;meta[^&gt;]*name="?robots[^&gt;]*&gt;' page.html
</code></pre>
<p>I ran it against the blog on <a href="https://justblogged.com">JustBlogged</a>, the platform I build, on 17 October 2026. It lives at <code>/blog</code> on the main domain:</p>
<table>
<thead>
<tr>
<th>URL requested</th>
<th>Response</th>
<th>Expected row</th>
</tr>
</thead>
<tbody><tr>
<td><code>http://justblogged.com/blog/blog-404-page</code></td>
<td>301 to <code>https://</code></td>
<td>Page with redirect</td>
</tr>
<tr>
<td><code>https://www.justblogged.com/blog/blog-404-page</code></td>
<td>301 to the apex</td>
<td>Page with redirect</td>
</tr>
<tr>
<td><code>/blog/blog-404-page/</code></td>
<td>200, canonical without the slash</td>
<td>Alternate page with proper canonical tag</td>
</tr>
<tr>
<td><code>/blog/blog-404-page?utm_source=x</code></td>
<td>200, canonical to the clean URL</td>
<td>Alternate page with proper canonical tag</td>
</tr>
<tr>
<td><code>/blog/BLOG-404-PAGE</code></td>
<td>200, canonical to lowercase</td>
<td>Alternate page with proper canonical tag</td>
</tr>
<tr>
<td><code>/blog/rss.xml</code></td>
<td>200, <code>X-Robots-Tag: noindex, follow</code></td>
<td>URL marked 'noindex'</td>
</tr>
<tr>
<td><code>/blog/search?q=dns</code></td>
<td>200, <code>X-Robots-Tag: noindex, follow</code></td>
<td>URL marked 'noindex'</td>
</tr>
<tr>
<td><code>/blog/tags/dns</code> (tag is "DNS")</td>
<td>404</td>
<td>Not found (404)</td>
</tr>
</tbody></table>
<p>The last row is one I didn't expect. Post slugs are case-insensitive and canonicalize to lowercase, but tag URLs are case-sensitive, so <code>/blog/tags/DNS</code> is 200 and <code>/blog/tags/dns</code> is 404. Any site linking to a tag with the wrong case shows up under Not found. Worth checking on whatever platform you're on, since slugs and tags are often handled by different routes.</p>
<h2>robots.txt under /blog does nothing</h2>
<p>This one catches a lot of blogs that live under a path. That blog serves a file at <code>/blog/robots.txt</code>, but Google's <a href="https://developers.google.com/search/docs/crawling-indexing/robots/robots_txt">robots.txt documentation</a> says crawlers only read the file at the top level of the host. Anything under <code>/blog</code> is governed by the root <code>robots.txt</code>, and a rule in the blog's own file has no effect on Google.</p>
<p>So the noindex header is what keeps <code>/blog/search</code> out. The root file doesn't block it, Googlebot fetches it, reads the header, and files it under "URL marked 'noindex'". If you block it in robots.txt instead, Google's <a href="https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls">canonicalization guide</a> warns it can still be indexed without its content, which is the "Indexed, though blocked by robots.txt" warning.</p>
<p>Platform defaults matter here too. WordPress sites created on 6.4 or later have <a href="https://make.wordpress.org/core/2023/10/16/changes-to-attachment-pages/">attachment pages disabled</a> and redirect them, so they show under Page with redirect. A site upgraded from an older version keeps them on, and they can turn up as indexable URLs.</p>
<h2>The order I fix rows that hold posts</h2>
<ol>
<li>Server errors and redirect errors. Google can't evaluate a page it can't fetch, and an outage or loop usually hits every post at once.</li>
<li>Posts under "URL marked 'noindex'". Usually a leftover staging setting, a theme option, or a per-post SEO toggle. Remove it, then inspect the live URL.</li>
<li>Soft 404s. Real 404 for pages that are gone, real content for pages thin enough to look like errors.</li>
<li>"Duplicate, Google chose different canonical than user". Google won't pick a declared canonical that isn't similar to the page, so check the target has the same content. Cross-posted copies on Medium, DEV or Hashnode are the usual suspect when the canonical tag on the copy is missing.</li>
<li>Posts under "Crawled - currently not indexed". No fix attached. What you control is whether the post adds something your other posts don't, and whether your own posts link to it. Resubmitting it every day doesn't help.</li>
</ol>
<p>Once every example URL under a reason is fixed, click Validate Fix on that reason. Google says validation typically takes up to about two weeks, and not to click it again until it has passed or failed.</p>
<p>If you moved hosts recently, most of the 404 and redirect rows are probably old URLs, and the migration redirects are the thing to fix before the report itself</p>
]]></content:encoded></item><item><title><![CDATA[Google Shows the Wrong Site Name for Your Blog: Where It Comes From and How to Fix It]]></title><description><![CDATA[Google picks the site name it shows above your blog's results by itself. It reads your home page, and]]></description><link>https://usamaejaz.hashnode.dev/google-shows-the-wrong-site-name-for-your-blog-where-it-comes-from-and-how-to-fix-it</link><guid isPermaLink="true">https://usamaejaz.hashnode.dev/google-shows-the-wrong-site-name-for-your-blog-where-it-comes-from-and-how-to-fix-it</guid><category><![CDATA[SEO]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Blogging]]></category><category><![CDATA[Google]]></category><dc:creator><![CDATA[Usama Ejaz]]></dc:creator><pubDate>Sat, 17 Oct 2026 15:41:02 GMT</pubDate><content:encoded><![CDATA[<p>Google picks the site name it shows above your blog's results by itself. It reads your home page, and the strongest preference you can state there is the <code>name</code> in <code>WebSite</code> structured data. It also looks at <code>og:site_name</code>, the <code>&lt;title&gt;</code>, headings and other text on the home page, according to <a href="https://developers.google.com/search/docs/appearance/site-names">Google's site names documentation</a> (last updated 10 December 2025). When Google shows the wrong name, the fix is to make those signals agree, add fallbacks in <code>alternateName</code>, and get the home page recrawled.</p>
<p>Two cases have no markup fix. Google only supports site names for a domain or subdomain, so a blog at <code>example.com/blog</code> can't get a name of its own. And Google can still decline a name it finds generic or already used by another site. The rest of this post covers how to see what your home page actually sends, what four platforms put there by default, and the fix for each wrong name you might be seeing.</p>
<h2>The markup Google asks for</h2>
<p>Google wants one <code>WebSite</code> object on the root URL of the domain or subdomain, with a <code>name</code> and the canonical home page <code>url</code>. It doesn't need to be on every page. <code>alternateName</code> is optional and takes a list in order of preference. Google's documentation suggests ending that list with your domain in lowercase, because it will strongly consider that as a backup when your preferred name isn't picked.</p>
<pre><code class="language-html">&lt;script type="application/ld+json"&gt;
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "Field Notes",
  "alternateName": ["Field Notes Blog", "fieldnotes.dev"],
  "url": "https://fieldnotes.dev/"
}
&lt;/script&gt;
</code></pre>
<p>If your platform already prints a <code>WebSite</code> block, change that block instead of pasting a second one into a custom head field. Google asks you to keep the site name properties in the same node and to avoid an additional <code>WebSite</code> block on the home page. The Rich Results Test doesn't cover site names, so check the syntax with the <a href="https://validator.schema.org/">Schema Markup Validator</a> instead.</p>
<p>This post is only about the home page, not the markup on individual posts.</p>
<h2>Check what your home page sends to Googlebot</h2>
<p>Viewing the source in your browser isn't always enough. On 16 October 2026 I requested the home pages of two Substack publications, <code>on.substack.com</code> and <code>lennysnewsletter.com</code>, twice each. With a Chrome user agent, the HTML had no JSON-LD at all. With Googlebot's user agent, the response was several times larger and contained a <code>WebSite</code> block with the publication name. Neither response had <code>og:site_name</code>.</p>
<p>So check the version Google receives. The URL Inspection tool in Search Console shows the HTML Google crawled; <a href="https://support.google.com/webmasters/answer/9012289">Google's help page for the tool</a> explains the live test. For a quick look from a terminal, this fetches the home page with a Googlebot user agent and prints the title, <code>og:site_name</code> and the start of any <code>WebSite</code> block. The <code>tr</code> step flattens the HTML so JSON-LD spread over several lines still matches.</p>
<pre><code class="language-shell">curl -sL -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" https://example.com/ \
  | tr -s '\n\t ' ' ' \
  | grep -oE '&lt;title[^&gt;]*&gt;[^&lt;]*|og:site_name"[^&gt;]*|"@type" ?: ?"WebSite"[^}]{0,200}'
</code></pre>
<p>Some hosts answer scripted requests with a bot challenge instead of the page. Bear Blog and Medium returned Cloudflare challenge pages to my requests, and one Hashnode blog returned a Vercel security checkpoint. URL Inspection is the reliable check in that case.</p>
<h2>What four platforms put on a blog's home page</h2>
<p>I checked live home pages on 16 October 2026. Each row describes what the sites I sampled sent, which is the platform default unless the owner changed it.</p>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Sites checked</th>
<th>Home page markup</th>
<th>Where to change the name</th>
</tr>
</thead>
<tbody><tr>
<td>Ghost</td>
<td><code>demo.ghost.io</code>, <code>platformer.news</code></td>
<td><code>WebSite</code> with the site title as <code>name</code>, plus matching <code>og:site_name</code></td>
<td>The site title. No <code>alternateName</code> in the default output.</td>
</tr>
<tr>
<td>Substack</td>
<td><code>on.substack.com</code>, <code>lennysnewsletter.com</code></td>
<td><code>WebSite</code> with the publication name, only in the Googlebot response. Title reads "Name</td>
<td>Substack". No <code>og:site_name</code>.</td>
</tr>
<tr>
<td>Hashnode</td>
<td><code>townhall.hashnode.com</code>, <code>blog.greenroots.info</code></td>
<td><code>Blog</code>, not <code>WebSite</code>, with the full blog title as <code>name</code>; the same text in <code>og:site_name</code> and <code>&lt;title&gt;</code></td>
<td>The blog title.</td>
</tr>
<tr>
<td>WordPress</td>
<td><code>yoast.com</code>, <code>longreads.com</code>, <code>themeshaper.com</code>, <code>ma.tt</code></td>
<td>yoast.com and longreads.com had a <code>WebSite</code> node. ThemeShaper and ma.tt had <code>og:site_name</code> but no <code>WebSite</code> node. WordPress core doesn't print JSON-LD itself, so the node depends on a plugin or theme.</td>
<td>With Yoast SEO: Settings, then Site basics, which has <a href="https://yoast.com/help/yoast-seo-settings-site-basics/">Website name and Alternate website name fields</a>.</td>
</tr>
</tbody></table>
<p>Hashnode's choice of <code>Blog</code> is a valid schema.org type, but Google's guide only describes <code>WebSite</code>, and it doesn't say whether a <code>Blog</code> node's name is read as a site name preference. On those blogs the title and <code>og:site_name</code> carry the same text, so the signals at least agree. Long titles like "GreenRoots Blog - A Blog by Tapas Adhikary" are what Google warns may be truncated on some devices.</p>
<h2>Match the wrong name to its cause</h2>
<p>The name Google shows usually tells you which rule you've run into.</p>
<h3>Google shows your domain instead of a name</h3>
<p>This is Google's fallback when it isn't confident in the name you gave. Confirm there is a <code>WebSite</code> block on the root URL and that it validates. Then make the visible home page agree with it: if the structured data says "Field Notes" and the title says "Home | My Blog", you're sending two answers. Add a shorter or more specific <code>alternateName</code>. A name that describes a category rather than a publication, like "Tech Blog", is one Google says it is unlikely to pick.</p>
<h3>Google still shows the old name after a rename</h3>
<p>The site name comes from the home page, so nothing changes until Google recrawls it. Request indexing for the home page in URL Inspection. Google's guide says crawling can take from several days to several weeks, and internal pages pick up the new name only after they are recrawled too. If you changed domains at the same time, the redirects and the Change of Address tool have to be done first.</p>
<h3>A subdomain blog shows your main site's name</h3>
<p>Google says that when a subdomain's home page has no structured data, the domain-level site name may be used as a fallback. If <code>blog.example.com</code> should carry its own name, its root URL needs its own <code>WebSite</code> block. If showing the company name is fine with you, there is nothing to fix.</p>
<h3>A blog at /blog shows the main site's name</h3>
<p>This is the subdirectory rule, and markup won't change it. Google's own example marks <code>https://example.com/news</code> as unsupported. My own product blog is in that position: it lives under <code>/blog</code> on the main domain, its home page prints a valid <code>WebSite</code> block with its own name, and under Google's rules that block can't give it a name separate from the one Google uses for the domain. For most company blogs that's the right result. If a separate name matters more than sharing the main domain, the blog has to move to a subdomain.</p>
<h3>The name differs between www and the apex, or http and https</h3>
<p>Google asks for the same structured data on every duplicate home page, and when a variant redirects, it uses the redirect target. If every variant of your address redirects to one home page, there is nothing to align. A missing redirect leaves two home pages that each need the same markup. Pick one and redirect the other.</p>
<h3>Google shows an acronym or a different form of your name</h3>
<p>Google says it may choose an acronym when the site is better known by one, and it generally won't give the same name to two sites that are global in nature. List the forms you're willing to accept in <code>alternateName</code>, most preferred first. As a last resort, Google's documentation says you can set the lowercase domain itself as <code>name</code>, and it will generally select that.</p>
<p>I checked all of this while wiring the <code>WebSite</code> block into the themes on <a href="https://justblogged.com">JustBlogged</a>. The Substack result surprised me the most... the markup only shows up when you ask as Googlebot, so a view-source check says it isn't there</p>
]]></content:encoded></item><item><title><![CDATA[Mixed Content After Moving a Blog to HTTPS: Which http:// Images Still Load]]></title><description><![CDATA[Once a blog is on HTTPS, every image, script, stylesheet and iframe its pages still load over http://]]></description><link>https://usamaejaz.hashnode.dev/mixed-content-after-moving-a-blog-to-https-which-http-images-still-load</link><guid isPermaLink="true">https://usamaejaz.hashnode.dev/mixed-content-after-moving-a-blog-to-https-which-http-images-still-load</guid><category><![CDATA[webdev]]></category><category><![CDATA[Security]]></category><category><![CDATA[https]]></category><category><![CDATA[Blogging]]></category><dc:creator><![CDATA[Usama Ejaz]]></dc:creator><pubDate>Sat, 17 Oct 2026 15:40:43 GMT</pubDate><content:encoded><![CDATA[<p>Once a blog is on HTTPS, every image, script, stylesheet and iframe its pages still load over <code>http://</code> is mixed content, and current browsers no longer handle it one way. A plain <code>&lt;img src="http://..."&gt;</code> is rewritten to <code>https://</code> and loads, but only if the image host answers on HTTPS; there is no fallback to the old address. Scripts, stylesheets and iframes are blocked. Responsive images are blocked too: any <code>http://</code> URL inside <code>srcset</code> or a <code>&lt;picture&gt;</code> source stops the image from rendering, even when the same tag's <code>src</code> is already <code>https://</code>.</p>
<p>That last case is easy to miss after a migration, because a find-and-replace aimed at <code>src="http://</code> or a quick look at the post's main image catches the <code>src</code> and leaves the <code>srcset</code>. Below is what I measured in Chromium, why the spec treats responsive images differently, a script that lists every <code>http://</code> subresource on a live blog, and the order I'd fix them in.</p>
<h2>What Chromium 141 did with each http:// reference</h2>
<p>On October 15, 2026 I ran a headless Chromium 141.0.7390.37 (through Playwright 1.56.1) against a test page served over HTTPS. The page pulled resources from two local hosts on the standard ports: one that served every file over both HTTP and HTTPS, and one that only spoke HTTP, the way an old personal image server or a lapsed CDN might. The server log recorded which scheme each request actually arrived on, and the page reported whether each image decoded. I ran the same page a second time with an <code>upgrade-insecure-requests</code> meta tag in the head.</p>
<table>
<thead>
<tr>
<th>Reference in the HTML</th>
<th>Default behavior</th>
<th>With <code>upgrade-insecure-requests</code></th>
</tr>
</thead>
<tbody><tr>
<td><code>&lt;img src="http://..."&gt;</code>, host also serves HTTPS</td>
<td>Upgraded to https, loads</td>
<td>Upgraded, loads</td>
</tr>
<tr>
<td><code>&lt;img src="http://..."&gt;</code>, host is HTTP only</td>
<td>Upgraded, connection refused, broken image</td>
<td>Same: broken image</td>
</tr>
<tr>
<td><code>&lt;img srcset="http://... 1x"&gt;</code></td>
<td>Blocked</td>
<td>Upgraded, loads</td>
</tr>
<tr>
<td><code>&lt;img src="https://..." srcset="http://... 300w, http://... 1024w"&gt;</code></td>
<td>Blocked; the browser picked a srcset candidate and never used the https <code>src</code></td>
<td>Upgraded, loads</td>
</tr>
<tr>
<td><code>&lt;picture&gt;&lt;source srcset="http://..."&gt;</code></td>
<td>Blocked; the fallback <code>&lt;img&gt;</code> was not used</td>
<td>Upgraded, loads</td>
</tr>
<tr>
<td>Inline <code>style="background-image:url(http://...)"</code></td>
<td>Upgraded, loads</td>
<td>Upgraded, loads</td>
</tr>
<tr>
<td><code>&lt;link rel="stylesheet" href="http://..."&gt;</code></td>
<td>Blocked</td>
<td>Upgraded, applied</td>
</tr>
<tr>
<td><code>&lt;script src="http://..."&gt;</code></td>
<td>Blocked</td>
<td>Upgraded, ran</td>
</tr>
<tr>
<td><code>&lt;iframe src="http://..."&gt;</code></td>
<td>Blocked</td>
<td>Upgraded, loads</td>
</tr>
</tbody></table>
<p>The console messages match the two outcomes. An upgraded image logs a warning ending in "This request was automatically upgraded to HTTPS", and a blocked one logs an error ending in "This request has been blocked; the content must be served over HTTPS." If you open DevTools on a migrated post and see only warnings, the page is relying on the upgrade, and each of those images depends on its host keeping HTTPS working.</p>
<h2>Why a responsive image breaks when its plain src would have loaded</h2>
<p>The automatic upgrade comes from the W3C <a href="https://w3c.github.io/webappsec-mixed-content/">Mixed Content Level 2</a> draft. It upgrades requests whose destination is an image, audio or video, then lists an exception: a request whose destination is "image" and whose initiator is "imageset" is left alone, and the spec gives the reason as "for historical reasons". Requests that aren't upgraded are blocked. MDN's <a href="https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Mixed_content">mixed content reference</a> spells out the practical meaning: an <code>&lt;img&gt;</code> set through <code>src</code> is upgradable, while one set "via <code>srcset</code> or <code>&lt;picture&gt;</code>" is blockable.</p>
<p>All three engines now apply the upgrade to plain images. Chrome announced the plan in <a href="https://blog.chromium.org/2019/10/no-more-mixed-messages-about-https.html">2019</a>, and its update on that post says mixed images are "autoupgraded and blocked if they fail to load over https://". Firefox followed in <a href="https://blog.mozilla.org/security/2024/06/05/firefox-will-upgrade-more-mixed-content-in-version-127/">version 127</a>, where images, audio and video "are not loaded if they are unavailable via HTTPS". WebKit's <a href="https://webkit.org/blog/15865/webkit-features-in-safari-18-0/">Safari 18.0 notes</a> say images and media "will now be auto-upgraded to HTTPS, in adherence with Mixed Content Level 2". I only ran Chromium, so the srcset result above is measured in the engine behind Chrome and Edge, and for Firefox and Safari it follows from the spec rather than from my test.</p>
<p>The practical consequence is that the scheme in <code>srcset</code> decides the image. A browser that chooses a candidate from <code>srcset</code> doesn't go back to <code>src</code> when that candidate is blocked, which is why the row with an https <code>src</code> still rendered as a broken image.</p>
<h2>List every http:// subresource on the live blog</h2>
<p>Checking posts one at a time in DevTools doesn't scale to an archive of a few hundred. The script below reads your sitemap, parses each page, and prints every <code>http://</code> subresource with the outcome the table predicts. For references a browser would upgrade, it also requests the <code>https://</code> version, so an image that will break is labelled <code>BROKEN</code> before a reader finds it. It uses only the Python standard library.</p>
<pre><code class="language-python">#!/usr/bin/env python3
"""Find http:// subresources on an HTTPS blog and predict what browsers do with each.
Usage: python3 mixed_content_scan.py https://example.com/sitemap.xml  (or page URLs)"""
import re, sys, ipaddress, urllib.request
from html.parser import HTMLParser
from urllib.parse import urlsplit

UA = {"User-Agent": "mixed-content-scan/1.0"}

def get(url):
    with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=20) as r:
        return r.read().decode("utf-8", "replace")

def https_ok(url):
    u = "https://" + url[len("http://"):]
    try:
        with urllib.request.urlopen(urllib.request.Request(u, headers=UA), timeout=10) as r:
            return r.status &lt; 400
    except Exception:
        return False

def is_ip(url):
    try:
        ipaddress.ip_address(urlsplit(url).hostname or "")
        return True
    except ValueError:
        return False

CSS_URL = re.compile(r"url\(\s*['\"]?(http://[^'\")\s]+)", re.I)

class Scan(HTMLParser):
    def __init__(self):
        super().__init__()
        self.found, self.in_picture, self.in_style = [], False, False
    def add(self, where, url, kind):
        self.found.append((where, url, kind))
    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag == "picture": self.in_picture = True
        if tag == "style": self.in_style = True
        for part in (a.get("srcset") or "").split(","):
            u = part.strip().split(" ")[0]
            if u.startswith("http://"): self.add(f"&lt;{tag} srcset&gt;", u, "blocked")
        src = a.get("src") or ""
        if src.startswith("http://"):
            if tag == "source" and self.in_picture: self.add("&lt;picture&gt;&lt;source src&gt;", src, "blocked")
            elif tag in ("img", "audio", "video", "source"): self.add(f"&lt;{tag} src&gt;", src, "upgraded")
            else: self.add(f"&lt;{tag} src&gt;", src, "blocked")
        if tag == "video" and (a.get("poster") or "").startswith("http://"):
            self.add("&lt;video poster&gt;", a["poster"], "upgraded")
        if tag == "link" and "stylesheet" in (a.get("rel") or "") and (a.get("href") or "").startswith("http://"):
            self.add("&lt;link stylesheet&gt;", a["href"], "blocked")
        for u in CSS_URL.findall(a.get("style") or ""):
            self.add("inline style url()", u, "upgraded")
    def handle_endtag(self, tag):
        if tag == "picture": self.in_picture = False
        if tag == "style": self.in_style = False
    def handle_data(self, data):
        if self.in_style:
            for u in CSS_URL.findall(data): self.add("&lt;style&gt; url()", u, "upgraded (blocked if a font)")

def pages(arg):
    if arg.endswith(".xml"):
        locs = re.findall(r"&lt;loc&gt;\s*([^&lt;\s]+)\s*&lt;/loc&gt;", get(arg))
        out = []
        for l in locs:
            out += pages(l) if l.endswith(".xml") else [l]
        return out
    return [arg]

def main():
    urls = [p for a in sys.argv[1:] for p in pages(a)]
    total, checked = 0, {}
    for page in urls:
        try:
            s = Scan(); s.feed(get(page))
        except Exception as e:
            print(f"ERROR  {page}  {e}"); continue
        for where, u, kind in s.found:
            total += 1
            if kind.startswith("upgraded"):
                if is_ip(u): kind = "blocked (IP address host)"
                else:
                    if u not in checked: checked[u] = https_ok(u)
                    if not checked[u]: kind = "upgraded, but https copy fails: BROKEN"
            print(f"{kind:40} {where:22} {u}  on {page}")
    print(f"\n{len(urls)} pages scanned, {total} http:// subresources found")

if __name__ == "__main__":
    main()
</code></pre>
<p>Run it with your sitemap or with individual post URLs:</p>
<pre><code class="language-shell">python3 mixed_content_scan.py https://example.com/sitemap.xml
</code></pre>
<p>It prints one line per reference. With illustrative URLs, the output looks like this:</p>
<pre><code class="language-plaintext">blocked                                  &lt;img srcset&gt;           http://old.example.com/wp-content/uploads/hero-1024x576.jpg  on https://example.com/hello-world
upgraded, but https copy fails: BROKEN   &lt;img src&gt;              http://photos.example.net/2014/cat.jpg  on https://example.com/my-cat
</code></pre>
<p>I checked the classifications against the Chromium test page above, then ran it over the 48 URLs in my own blog's sitemap, which returned no <code>http://</code> subresources. It reads the HTML the server sends, so it won't see URLs inside external stylesheets or markup that JavaScript inserts after load, and it marks every <code>url()</code> inside a <code>&lt;style&gt;</code> block as an image even though a font loaded that way is blocked. Those gaps are small on most blogs, but if a theme uses custom fonts from an old host, open one page in DevTools as well.</p>
<h2>Fix each reference at its source, by the kind of host</h2>
<p>The script's output falls into four groups, and each has a different fix. References to your own old address are absolute <code>http://yourdomain.com/...</code> URLs written into posts before the site had HTTPS. Rewrite them to <code>https://</code> in the content itself. On a WordPress install, WP-CLI's <a href="https://developer.wordpress.org/cli/commands/search-replace/">search-replace</a> handles serialized data, and its documented examples skip the <code>guid</code> column. Run it with <code>--dry-run</code> first:</p>
<pre><code class="language-shell">wp search-replace 'http://example.com' 'https://example.com' --skip-columns=guid --dry-run
</code></pre>
<p>If you're leaving WordPress, do the same replacement in the export file before importing it anywhere, so the new platform never stores the old scheme.</p>
<p>Third-party hosts that answer on HTTPS only need the scheme changed. The upgrade already loads them, but editing the URL removes the dependency on browser behavior, and it fixes the copy of your post that RSS readers and scrapers receive.</p>
<p>Hosts with no working HTTPS are where readers already see broken images. Download the files while the HTTP address still answers and serve them from your own blog, then point every reference at the new copy. If the HTTP address is already gone, the image has to come from a backup or the Wayback Machine.</p>
<p>Scripts and iframes are usually old embed codes and widgets. A blocked <code>http://</code> embed is often a service that has since moved to HTTPS under a new snippet, so replace the snippet rather than editing the scheme.</p>
<h2>Add upgrade-insecure-requests after the rewrite</h2>
<p>The <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/upgrade-insecure-requests">upgrade-insecure-requests</a> directive tells the browser to rewrite every non-navigational <code>http://</code> request to <code>https://</code> before sending it. In the test, it rescued every blocked row: srcset images, the picture source, the stylesheet, the script and the iframe. It can be sent as a response header or placed in the page head:</p>
<pre><code class="language-html">&lt;meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests"&gt;
</code></pre>
<p>It works as a safety net for references the rewrite missed. It can't fix a host without HTTPS, which failed in both runs, and MDN notes there's no fallback to HTTP. It also only affects browsers loading your pages, so the <code>http://</code> URLs remain in your stored content and in your feed. The upgraded script row shows the other reason to keep it as a backstop: a script you would have noticed as blocked now runs silently from whatever currently answers at that address.</p>
<p>On a host where you control headers, send it as a header instead so it covers every response.</p>
<p>I went down this path while moving old posts onto custom domains for <a href="https://justblogged.com">JustBlogged</a>. The srcset row is the one I didn't expect... a find-and-replace on <code>src</code> looks done, and the image still doesn't render</p>
]]></content:encoded></item><item><title><![CDATA[DNSSEC When Moving a Blog's DNS: Remove the DS Record Before the Nameservers Change]]></title><description><![CDATA[Why a stale DS record makes a blog return SERVFAIL after a nameserver change, three curl queries to check the DNSSEC chain, and the safe order for moving…]]></description><link>https://usamaejaz.hashnode.dev/dnssec-when-moving-a-blog-s-dns-remove-the-ds-record-before-the-nameservers-change</link><guid isPermaLink="true">https://usamaejaz.hashnode.dev/dnssec-when-moving-a-blog-s-dns-remove-the-ds-record-before-the-nameservers-change</guid><category><![CDATA[dns]]></category><category><![CDATA[Security]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[Usama Ejaz]]></dc:creator><pubDate>Wed, 14 Oct 2026 05:53:21 GMT</pubDate><content:encoded><![CDATA[<p>DNSSEC can only break a blog migration at one moment: when the domain's nameservers change. Everything else about a host move leaves it alone.</p>
<p>Here is the failure. Your registrar holds a DS record that tells validating resolvers which key should be signing your zone. Move the zone to a new DNS provider while that DS record still describes the old provider's key, and resolvers like 1.1.1.1, 8.8.8.8 and Quad9 answer with SERVFAIL instead of an IP address. The blog disappears for anyone behind a validating resolver and keeps working for everyone else. So it can look fine on your laptop and be down for half your readers, which is the worst kind of outage to debug.</p>
<p>The safe order is short: remove the DS record at the registrar, wait out its TTL, change the nameservers, then turn on DNSSEC at the new provider and add its DS record. The rest of this post is how to tell whether it applies to you, three queries that show the state of the chain with nothing but curl, and what to do if you already switched and things broke.</p>
<h2>A normal host move doesn't touch DNSSEC</h2>
<p>Most host moves are an edit inside your existing zone: a new A record for the apex, a new CNAME for <code>www</code>. The provider that signs your zone signs the edited records too, and the DS record at the registrar still matches because the keys didn't change.</p>
<p>DNSSEC becomes your problem when the nameservers change. That covers moving DNS to Cloudflare or Route 53, switching from your registrar's default nameservers to custom ones, going back the other way, and transferring the domain to a registrar whose DNS you'll use instead. In every one of those, the party signing the zone changes while the DS record at the registry stays where it was.</p>
<h2>Check the chain with three queries</h2>
<p>Google Public DNS and Cloudflare's 1.1.1.1 both answer DNS queries as JSON over HTTPS. Google's <a href="https://developers.google.com/speed/public-dns/docs/doh/json">JSON API</a> takes <code>cd=1</code> to disable DNSSEC validation, and <a href="https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/dns-json/">Cloudflare's</a> takes the same parameter. Comparing a normal answer with a <code>cd=1</code> answer tells you whether DNSSEC is the reason a name doesn't resolve.</p>
<pre><code class="language-bash"># 1. Is there a DS record at the registry?
curl -s 'https://dns.google/resolve?name=example.com&amp;type=DS'

# 2. Does the name resolve with validation on?
curl -s 'https://dns.google/resolve?name=example.com&amp;type=A'

# 3. Does it resolve with validation off?
curl -s 'https://dns.google/resolve?name=example.com&amp;type=A&amp;cd=1'
</code></pre>
<p>I ran queries 2 and 3 on October 14, 2026 against <code>dnssec-failed.org</code>, a test domain whose DS record deliberately doesn't match the keys its nameservers publish. With validation on, Google returned:</p>
<pre><code class="language-json">{"Status":2, "AD":false, "CD":false,
 "Comment":"DNSSEC validation failure. Check http://dnsviz.net/d/dnssec-failed.org/dnssec/ ...",
 "extended_dns_errors":[{"info_code":9,"extra_text":"No DNSKEY matches DS RRs of dnssec-failed.org"}]}
</code></pre>
<p>With <code>cd=1</code>, the same resolver returned <code>"Status":0</code> and the A record <code>96.99.227.255</code>. Cloudflare gave the same pair through <code>https://cloudflare-dns.com/dns-query</code> (send the header <code>accept: application/dns-json</code>), with the reason in its Comment field as <code>EDE(9): DNSKEY Missing</code>. The records exist and the nameservers answer. Validation is the only thing stopping resolution.</p>
<table>
<thead>
<tr>
<th>DS query</th>
<th>Query 2</th>
<th>Query 3 (cd=1)</th>
<th>What it means</th>
</tr>
</thead>
<tbody><tr>
<td>No answer</td>
<td>Status 0</td>
<td>Status 0</td>
<td>DNSSEC is off at the registry. A nameserver change won't trip on it.</td>
</tr>
<tr>
<td>Answer present</td>
<td>Status 0, <code>"AD":true</code></td>
<td>Status 0</td>
<td>The chain validates. Remove the DS record before you change nameservers.</td>
</tr>
<tr>
<td>Answer present</td>
<td>Status 2 (SERVFAIL)</td>
<td>Status 0 with an answer</td>
<td>The chain is broken. Fix the DS record, not your A or CNAME records.</td>
</tr>
<tr>
<td>Either</td>
<td>Status 2</td>
<td>Status 2</td>
<td>Something other than DNSSEC is failing, usually nameservers that don't answer for the zone.</td>
</tr>
</tbody></table>
<p>The extended error code narrows it down further. <a href="https://www.rfc-editor.org/rfc/rfc8914.html">RFC 8914</a> defines code 9, DNSKEY Missing, as "A DS record existed at a parent, but no supported matching DNSKEY record could be found for the child". That is exactly what a DS record left over from the old provider produces. Code 10, RRSIGs Missing, means signatures were expected and none came back, which fits a new provider that isn't signing the zone yet. Cloudflare lists the codes 1.1.1.1 returns on its <a href="https://developers.cloudflare.com/1.1.1.1/infrastructure/extended-dns-error-codes/">Extended DNS Error codes page</a>. If you want a picture of the whole chain, <a href="https://dnsviz.net/">DNSViz</a> and Verisign's <a href="https://dnssec-analyzer.verisignlabs.com/">DNSSEC Analyzer</a> both take a domain and show where it breaks.</p>
<p>Look at the TTL on the DS answer too, because it decides how long the move takes. On the same day, the DS record for <code>cloudflare.com</code> came back from 1.1.1.1 with a TTL of 86400 seconds (one day) and from Google with 7212, which is just the time left in Google's cache. A fresh answer shows the registry's full value. A cached one shows what remains.</p>
<h2>The order that works when DNS moves to a new provider</h2>
<p>Cloudflare's <a href="https://developers.cloudflare.com/dns/dnssec/">DNSSEC docs</a> and Route 53's guide to <a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/migrate-dns-domain-in-use.html">migrating a domain that's in use</a> describe the same sequence. Route 53 states the constraint plainly: "You can't have DNSSEC signing enabled across two providers at the same time."</p>
<ol>
<li>Recreate every record at the new provider: the blog's A and CNAME records, MX for email, and any TXT verification records. Leave DNSSEC off there for now.</li>
<li>Remove the DS record at your registrar, and leave signing on at the old provider. Route 53's <a href="https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-configuring-dnssec-disable.html">disable procedure</a> warns that "some registrars have scheduled DS removal, for example once a day", so confirm it's gone by asking the registry's own nameserver, for example <code>dig +norec DS example.com @a.gtld-servers.net</code> for a .com.</li>
<li>Wait out the DS TTL. Cloudflare says it is commonly 24 to 48 hours for most TLDs, and its <a href="https://developers.cloudflare.com/dns/dnssec/troubleshooting/">troubleshooting page</a> says to wait at least 1.5 times the TTL: "If the DS TTL is 24 hours, wait 36 hours."</li>
<li>Change the nameservers at the registrar and wait for the old NS TTL. Route 53 notes the NS record's typical TTL is two days unless you lowered it beforehand.</li>
<li>Turn on DNSSEC at the new provider and add its DS record at the registrar. Cloudflare Registrar <a href="https://developers.cloudflare.com/registrar/get-started/enable-dnssec/">adds the DS record itself</a>, which "can take one to two days", and registrars that read CDS records can pick it up automatically. Everywhere else you paste the key tag, algorithm, digest type and digest by hand.</li>
<li>Run the three queries again. Query 2 should now come back with <code>"AD":true</code>.</li>
</ol>
<p>Between steps 2 and 5 the domain resolves without DNSSEC protection. <a href="https://www.rfc-editor.org/rfc/rfc6781.html">RFC 6781</a> calls that window "the only viable operation" when the old and new providers don't cooperate. The way around it is multi-signer DNSSEC from <a href="https://www.rfc-editor.org/rfc/rfc8901.html">RFC 8901</a>, where both providers' keys are published during the move. Cloudflare supports it as an <a href="https://developers.cloudflare.com/dns/dnssec/dnssec-active-migration/">active migration</a>, but only if the old provider lets you add DNSKEY records at the zone apex. I think for a blog that's overkill... I'd take the unsigned day or two over coordinating key imports between two DNS companies, unless the old provider already documents multi-signer support.</p>
<h2>What registrars do on their own when you change nameservers</h2>
<p>Some registrars handle part of this for you, and their help pages differ in ways that matter mid-move. These are from each registrar's docs as of October 14, 2026.</p>
<table>
<thead>
<tr>
<th>Registrar</th>
<th>Documented behavior</th>
</tr>
</thead>
<tbody><tr>
<td>Squarespace Domains</td>
<td>DNSSEC is "automatically enabled" on supported TLDs and "automatically disabled if you switch to using custom nameservers" (<a href="https://support.squarespace.com/hc/en-us/articles/31094668921229-DNSSEC-for-Squarespace-domains">source</a>). You can add one DS record for custom nameservers.</td>
</tr>
<tr>
<td>Namecheap</td>
<td>Automatic on BasicDNS and PremiumDNS (<a href="https://www.namecheap.com/support/knowledgebase/article.aspx/9723/2232/managing-dnssec-for-domains-pointed-to-premium-or-basicdns/">source</a>). With custom DNS you manage DS records yourself, and turning DNSSEC back on re-activates "the records you previously configured" (<a href="https://www.namecheap.com/support/knowledgebase/article.aspx/9722/2232/managing-dnssec-for-domains-pointed-to-custom-dns/">source</a>).</td>
</tr>
<tr>
<td>Porkbun</td>
<td>Recommends deleting DNSSEC records before transferring a domain in, then turning on Porkbun's DNSSEC or adding your own afterward (<a href="https://kb.porkbun.com/article/89-how-to-transfer-a-domain-to-porkbun-with-no-downtime">source</a>).</td>
</tr>
<tr>
<td>Cloudflare Registrar</td>
<td>Publishes the DS record automatically from the zone's CDS records. "There is currently no way to manually control the exact timing of DS publication" (<a href="https://developers.cloudflare.com/dns/dnssec/">source</a>).</td>
</tr>
</tbody></table>
<p>Namecheap is the one that bites. Switch to custom nameservers, re-enable DNSSEC, and the old DS values come back, describing keys the new provider doesn't have. Replace them with the new provider's values before you save. Squarespace disables DNSSEC as part of the nameserver change itself, so a resolver that cached the DS record earlier can still fail until that cache expires. If your registrar lets you remove the DS record as a separate step, do it a day or two ahead.</p>
<h2>Already switched and getting SERVFAIL</h2>
<p>Go to the registrar and either delete the DS record or replace it with the one your new provider shows. Deleting is quicker to get right, and you can add the new DS record once the domain resolves. Google's <a href="https://developers.google.com/speed/public-dns/docs/troubleshooting/domains">troubleshooting page</a> gives the same fix: "ask your domain registrar to remove stale DS records."</p>
<p>Resolvers that cached the old DS record keep failing until its TTL runs out, so some readers can see the error for up to a day after the fix. Cloudflare's <a href="https://one.one.one.one/purge-cache/">purge cache tool</a> and Google's <a href="https://developers.google.com/speed/public-dns/cache">flush cache page</a> each clear one resolver's cache for a name you enter. That helps readers on those two resolvers and does nothing for an ISP's resolver. Don't touch your A and CNAME records while you wait. They aren't the problem, and editing them adds a second unknown.</p>
<p>A broken chain also stops your new host from getting a certificate. Under CA/Browser Forum <a href="https://cabforum.org/2025/06/18/ballot-sc-085v2-require-validation-of-dnssec-when-present-for-caa-and-dcv-lookups/">ballot SC-085v2</a>, CAs have had to validate DNSSEC on CAA lookups since March 15, 2026, and the <a href="https://cabforum.org/working-groups/server/baseline-requirements/documents/">Baseline Requirements</a> say a validation error such as SERVFAIL "MUST NOT be treated as permission to issue." So a host that issues HTTPS automatically can sit at "pending" until the DS record is fixed, with no useful error. I wrote up the rest of that lookup in <a href="https://usamaejaz.hashnode.dev/caa-records-and-your-blog-host-which-record-applies-and-which-ca-to-allow">CAA records and your blog host</a>.</p>
<p>I went through all of this while wiring custom domains on <a href="https://justblogged.com">JustBlogged</a>. If a domain is stuck on "pending" after a nameserver change, those three queries tell you in a minute whether DNSSEC is the reason</p>
]]></content:encoded></item><item><title><![CDATA[How OAuth Works for a Remote MCP Server]]></title><description><![CDATA[A walkthrough of remote MCP server OAuth using a live server: the 401, protected resource metadata, client registration, PKCE, tokens and scopes.]]></description><link>https://usamaejaz.hashnode.dev/how-oauth-works-for-a-remote-mcp-server</link><guid isPermaLink="true">https://usamaejaz.hashnode.dev/how-oauth-works-for-a-remote-mcp-server</guid><category><![CDATA[mcp]]></category><category><![CDATA[oauth]]></category><category><![CDATA[AI]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Usama Ejaz]]></dc:creator><pubDate>Mon, 12 Oct 2026 05:51:57 GMT</pubDate><content:encoded><![CDATA[<p>When you add a remote MCP server to Claude, Cursor or Claude Code, a browser window opens, you log in, and the client is connected. No API key, no config file with a secret in it. A fair amount happens in those few seconds, and most of it is standard OAuth 2.1 with a few pieces the MCP spec adds on top.</p>
<p>This post walks through that flow using a live remote MCP server I run at <code>https://justblogged.com/mcp</code> as the example. Every response below is real output from <code>curl</code>, trimmed to the relevant parts. The rules come from the <a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization">MCP authorization specification</a>.</p>
<h2>Step 1: The unauthenticated request</h2>
<p>Some MCP methods are public on this server. <code>initialize</code> and <code>tools/list</code> work without a token, so a client or a curious developer can see what the server does before signing in. Calling a tool that touches blog data is different:</p>
<pre><code class="language-bash">curl -i -X POST https://justblogged.com/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_blogs","arguments":{}}}'
</code></pre>
<pre><code class="language-http">HTTP/2 401
cache-control: no-store
www-authenticate: Bearer realm="OAuth", resource_metadata="https://justblogged.com/.well-known/oauth-protected-resource/mcp", scope="blogs:manage"
</code></pre>
<p>The <code>WWW-Authenticate</code> header is the starting point for everything else. It tells the client three things:</p>
<ul>
<li><code>Bearer</code>: send an access token in the <code>Authorization</code> header.</li>
<li><code>resource_metadata</code>: where to find metadata about this protected resource. This comes from RFC 9728.</li>
<li><code>scope</code>: which scope to ask for. Here there is only one, <code>blogs:manage</code>.</li>
</ul>
<p>A client that gets this response does not need any server-specific configuration. It has a URL to follow.</p>
<h2>Step 2: Protected resource metadata</h2>
<pre><code class="language-bash">curl https://justblogged.com/.well-known/oauth-protected-resource/mcp
</code></pre>
<pre><code class="language-json">{
  "resource": "https://justblogged.com/mcp",
  "authorization_servers": ["https://justblogged.com"],
  "scopes_supported": ["blogs:manage"],
  "bearer_methods_supported": ["header"],
}
</code></pre>
<p>This document answers "who issues tokens for this server?" The MCP server is the resource server. The authorization server could be a separate service like an identity provider, and <code>authorization_servers</code> is how the client finds it. In this case both live on the same origin.</p>
<p>The path matters. The metadata URL is the well-known prefix plus the path of the MCP endpoint (<code>/mcp</code>), which is how a host serving several protected resources can describe each one separately. The spec says clients should use the URL from <code>WWW-Authenticate</code> when it is present, and fall back to constructing the well-known URI when it is not.</p>
<p><code>resource</code> is also used later. The client sends it as the <code>resource</code> parameter (RFC 8707, Resource Indicators) in the authorization and token requests, so the token is issued for this specific server and is not meant to be accepted anywhere else.</p>
<h2>Step 3: Authorization server metadata</h2>
<p>Next, the client fetches the authorization server's metadata (RFC 8414):</p>
<pre><code class="language-bash">curl https://justblogged.com/.well-known/oauth-authorization-server
</code></pre>
<pre><code class="language-json">{
  "issuer": "https://justblogged.com",
  "authorization_endpoint": "https://justblogged.com/oauth/authorize",
  "token_endpoint": "https://justblogged.com/oauth/token",
  "registration_endpoint": "https://justblogged.com/oauth/register",
  "scopes_supported": ["blogs:manage"],
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"],
  "code_challenge_methods_supported": ["S256"],
  "authorization_response_iss_parameter_supported": true,
  "client_id_metadata_document_supported": true
}
</code></pre>
<p>The fields a client reads here:</p>
<ul>
<li><code>authorization_endpoint</code> and <code>token_endpoint</code>: where to send the user, and where to exchange the code.</li>
<li><code>code_challenge_methods_supported: ["S256"]</code>: PKCE is supported, with SHA-256. The MCP spec requires clients to check this and refuse to continue if PKCE is not advertised.</li>
<li><code>token_endpoint_auth_methods_supported</code> includes <code>none</code>: public clients, such as a desktop app or CLI that cannot keep a secret, are allowed.</li>
<li><code>registration_endpoint</code> and <code>client_id_metadata_document_supported</code>: the two ways a client can get a client ID. More on that next.</li>
</ul>
<p>This server does not serve <code>/.well-known/openid-configuration</code> (it returns 404). That is fine. The spec has clients try both OAuth and OpenID Connect discovery URLs, and either one is enough.</p>
<h2>Step 4: Getting a client ID</h2>
<p>Classic OAuth assumes a developer registers their app with the provider ahead of time. That does not work for MCP, where any client might connect to any server. The spec offers two answers.</p>
<h3>Dynamic client registration</h3>
<p>With Dynamic Client Registration (RFC 7591), the client registers itself at runtime:</p>
<pre><code class="language-bash">curl -X POST https://justblogged.com/oauth/register \
  -H 'Content-Type: application/json' \
  -d '{
    "client_name": "curl-test",
    "redirect_uris": ["http://127.0.0.1:33418/callback"],
    "grant_types": ["authorization_code", "refresh_token"],
    "response_types": ["code"],
    "token_endpoint_auth_method": "none"
  }'
</code></pre>
<pre><code class="language-json">{
  "client_id": "x9FmJdo4leive3by",
  "redirect_uris": ["http://127.0.0.1:33418/callback"],
  "client_name": "curl-test",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "client_id_issued_at": 1791646827
}
</code></pre>
<p><code>201 Created</code>, and the client has an ID. The loopback redirect URI is typical for CLI tools, which start a small local server to receive the code. The downside of this approach is that the server ends up with a registration for every client instance, and it knows nothing about who is behind a <code>client_name</code> beyond what the client claims.</p>
<h3>Client ID Metadata Documents</h3>
<p>The newer option, added in the 2025-11-25 revision of the spec, is a Client ID Metadata Document. Instead of registering, the client uses an HTTPS URL as its <code>client_id</code>. That URL serves a JSON document with the client's name and redirect URIs. The authorization server fetches it when the user arrives, and checks that the redirect URI in the request is listed there.</p>
<p>This ties a client ID to a domain the client's developer controls, and there is no registration table to grow. The metadata advertises <code>client_id_metadata_document_supported: true</code>, so clients that support it can use it, and clients that only know registration still have <code>/oauth/register</code>.</p>
<h2>Step 5: Authorization with PKCE</h2>
<p>Now the client sends the user to the browser. First it creates a PKCE pair:</p>
<pre><code class="language-bash">verifier=$(openssl rand -base64 48 | tr -d '=+/' | cut -c1-64)
challenge=$(printf '%s' "$verifier" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=')
</code></pre>
<p>Then it opens a URL like this:</p>
<pre><code class="language-text">https://justblogged.com/oauth/authorize
  ?response_type=code
  &amp;client_id=x9FmJdo4leive3by
  &amp;redirect_uri=http://127.0.0.1:33418/callback
  &amp;scope=blogs:manage
  &amp;state=&lt;random&gt;
  &amp;code_challenge=&lt;challenge&gt;
  &amp;code_challenge_method=S256
  &amp;resource=https://justblogged.com/mcp
</code></pre>
<p>The user logs in, sees a consent screen that says the client can read and manage blog content, settings, domains and themes, and approves. The browser is redirected to the loopback address with a <code>code</code>, the <code>state</code> value, and an <code>iss</code> parameter (the metadata says <code>authorization_response_iss_parameter_supported</code>), which lets the client confirm the response came from the server it started with.</p>
<p>PKCE is what makes this safe for a public client. The code alone is useless. Only the party holding the original <code>verifier</code> can exchange it, so a code intercepted on the way back cannot be redeemed by someone else.</p>
<h2>Step 6: The token</h2>
<p>The client exchanges the code at the token endpoint:</p>
<pre><code class="language-bash">curl -X POST https://justblogged.com/oauth/token \
  -d grant_type=authorization_code \
  -d code=&lt;code&gt; \
  -d redirect_uri=http://127.0.0.1:33418/callback \
  -d client_id=x9FmJdo4leive3by \
  -d code_verifier="$verifier" \
  -d resource=https://justblogged.com/mcp
</code></pre>
<p>The response contains an access token and, because <code>refresh_token</code> is a supported grant, a refresh token. From then on every MCP request carries:</p>
<pre><code class="language-http">Authorization: Bearer &lt;access_token&gt;
</code></pre>
<p>The metadata lists <code>header</code> as the only bearer method, which matches the spec: tokens go in the header, never in the URL. When the access token expires, the client uses the refresh token to get a new one without sending the user back to the browser. Clients like Claude Code store and refresh tokens on their own, which is why you sign in once.</p>
<p>If a token is wrong or unknown, the server rejects it. For example, a token request with a client ID that does not exist returns:</p>
<pre><code class="language-json">{"error":"invalid_client","error_description":"Client not found"}
</code></pre>
<h2>The scope, and its tradeoff</h2>
<p><code>blogs:manage</code> is the only scope. A token with it can do everything the signed-in account can do: list and read posts, publish, delete, change domains and edit themes, across every blog the account has access to. Normal account permissions still apply, so a member who cannot delete a blog in the dashboard cannot delete it over MCP either.</p>
<p>A single scope keeps the consent screen honest and simple, and it means every client gets the same capabilities. The cost is that there is no way to hand an agent read-only access, or write access to one blog and not the others. If you want an assistant to audit your archive, it gets the same token it would need to delete it.</p>
<p>Finer scopes, such as separate read and write, or scopes per blog, would fix that. For now, the practical move is to use clients you trust, keep work in draft, and ask the assistant to show destructive changes before running them.</p>
<h2>Summary of the flow</h2>
<ol>
<li>Client calls the MCP endpoint and gets <code>401</code> with <code>WWW-Authenticate</code>.</li>
<li>Client reads the protected resource metadata from <code>resource_metadata</code>.</li>
<li>Client reads the authorization server metadata and checks for <code>S256</code>.</li>
<li>Client gets a client ID through registration or a metadata document URL.</li>
<li>User approves in the browser; the client receives a code.</li>
<li>Client exchanges the code plus PKCE verifier for tokens, bound to the resource.</li>
<li>Every MCP request carries <code>Authorization: Bearer</code>.</li>
</ol>
<p>That is the whole handshake. The live responses above are from the <a href="https://justblogged.com">JustBlogged</a> MCP endpoint I used while building it.</p>
]]></content:encoded></item><item><title><![CDATA[CAA Records and Your Blog Host: Which Record Applies, and Which CA to Allow]]></title><description><![CDATA[Blog certificate stuck on pending? How CAA lookups follow CNAMEs, and which CA GitHub Pages, Netlify, Vercel, WordPress.com and others issue from.]]></description><link>https://usamaejaz.hashnode.dev/caa-records-and-your-blog-host-which-record-applies-and-which-ca-to-allow</link><guid isPermaLink="true">https://usamaejaz.hashnode.dev/caa-records-and-your-blog-host-which-record-applies-and-which-ca-to-allow</guid><category><![CDATA[dns]]></category><category><![CDATA[https]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[Usama Ejaz]]></dc:creator><pubDate>Mon, 12 Oct 2026 05:51:15 GMT</pubDate><content:encoded><![CDATA[<p>If your blog host says the domain is connected but the HTTPS certificate never shows up, and your DNS zone has a CAA record, that record probably doesn't name the certificate authority your host uses. A CAA record lists the CAs allowed to issue certificates for a name. Every public CA has to check it before issuing, and when your host's CA isn't on the list, the request is refused. Often the only sign you'll see is a domain stuck on "pending".</p>
<p>The fix is to add one line, but you need two facts first: which CAA record actually applies to your blog's hostname, and which CA your host issues from. The second one is less obvious than it should be. Some hosts don't document their CA, and for one major host the docs and the certificates it actually issues disagree. I checked both, using live DNS queries and Certificate Transparency logs, on October 11, 2026.</p>
<h2>Find the CAA record that applies to your blog's hostname</h2>
<p>A CA doesn't just look at your apex domain. <a href="https://www.rfc-editor.org/rfc/rfc8659.html">RFC 8659</a> has it start at the exact hostname on the certificate, follow any CNAME, and stop at the first CAA record set it finds. If there's nothing at that name, it moves up one label of <em>your</em> name and tries again. <a href="https://letsencrypt.org/docs/caa/">Let's Encrypt's CAA page</a> gives the order for <code>www.community.example.org</code> as that name, then <code>community.example.org</code>, then <code>example.org</code>, "stopping at the first CAA record it finds".</p>
<p>For a blog, that leads to three different outcomes depending on how you connected the domain:</p>
<ul>
<li>An apex domain set up with A records has no CNAME to follow, so the CAA records on your own apex are the ones that count.</li>
<li>A <code>www</code> or <code>blog</code> subdomain that CNAMEs to a host target which publishes its own CAA records gets the host's records. Yours are never reached, and you can't edit the host's.</li>
<li>A subdomain that CNAMEs to a target with no CAA records sends the lookup back up your own tree, to <code>example.com</code>. The target's parent domain is never consulted.</li>
</ul>
<p>That last case is easy to get wrong. <code>www.taniarascia.com</code> is a CNAME to <code>tania.netlify.app</code>, which has no CAA records. The parent zone <code>netlify.app</code> does have one, and it only allows DigiCert, through one specific account. Even so, Let's Encrypt issued a certificate for <code>www.taniarascia.com</code> on August 16, 2026, because the lookup went to <code>taniarascia.com</code> and not to <code>netlify.app</code>.</p>
<p>To see what a CA will see, query the exact hostname first and then each parent until something comes back:</p>
<pre><code>dig CAA www.example.com +noall +answer
dig CAA example.com +noall +answer
</code></pre>
<p>If <code>dig</code> isn't installed, Google's public resolver answers the same question over HTTPS:</p>
<pre><code>curl -s "https://dns.google/resolve?name=www.example.com&amp;type=CAA"
</code></pre>
<p>An empty answer at every level means no CAA restriction applies, and any CA may issue. In that case CAA isn't the reason your certificate is stuck. If the record you find lists only CAs your host doesn't use, you've found the problem.</p>
<h2>The certificate authority each blog host uses</h2>
<p>The table combines each host's documentation with what I observed on October 11, 2026. "Target CAA" is what the host's CNAME target returned when I queried it. Where a host publishes CAA there, those records govern any subdomain you point at it, whatever your own zone says. The last column is the value I'd put in your own record for an apex domain, or for a subdomain whose target has no CAA.</p>
<table>
<thead>
<tr>
<th>Host</th>
<th>What the docs say</th>
<th>Target CAA (observed)</th>
<th>Allow in your record</th>
</tr>
</thead>
<tbody><tr>
<td>GitHub Pages</td>
<td><a href="https://docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/troubleshooting-custom-domains-and-github-pages">Requires</a> a CAA record with the value <code>letsencrypt.org</code> if you use CAA</td>
<td><code>*.github.io</code>: letsencrypt.org, digicert.com, sectigo.com</td>
<td><code>letsencrypt.org</code></td>
</tr>
<tr>
<td>Netlify</td>
<td><a href="https://docs.netlify.com/manage/domains/secure-domains-with-https/https-ssl/">Let's Encrypt</a>, with an optional <code>accounturi</code> that limits issuance to Netlify's account</td>
<td>None on <code>yoursite.netlify.app</code></td>
<td><code>letsencrypt.org</code></td>
</tr>
<tr>
<td>Vercel</td>
<td><a href="https://vercel.com/docs/domains/troubleshooting">Let's Encrypt</a>: authorize it with <code>0 issue "letsencrypt.org"</code></td>
<td><code>cname.vercel-dns.com</code>: letsencrypt.org, pki.goog, sectigo.com, globalsign.com</td>
<td><code>letsencrypt.org</code></td>
</tr>
<tr>
<td>Cloudflare Pages</td>
<td><a href="https://developers.cloudflare.com/pages/configuration/custom-domains/">Let's Encrypt, Google Trust Services and SSL.com</a>, as both <code>issue</code> and <code>issuewild</code></td>
<td>Your own zone</td>
<td><code>letsencrypt.org</code>, <code>pki.goog</code>, <code>ssl.com</code></td>
</tr>
<tr>
<td>Ghost(Pro)</td>
<td><a href="https://ghost.org/help/using-custom-domains/">Automatic SSL</a>; no CA or CAA guidance</td>
<td><code>yoursite.ghost.io</code>: certainly.com, letsencrypt.org, digicert.com, sectigo.com</td>
<td>Not documented; ask support before restricting an apex</td>
</tr>
<tr>
<td>WordPress.com</td>
<td><a href="https://wordpress.com/support/https-ssl/">Let's Encrypt</a></td>
<td>Custom domains I checked carry Google Trust Services certificates</td>
<td><code>letsencrypt.org</code> and <code>pki.goog</code></td>
</tr>
<tr>
<td>Substack</td>
<td>No CA documented</td>
<td>None on <code>target.substack-custom-domains.com</code>; a custom domain I checked carries Google Trust Services certificates</td>
<td><code>pki.goog</code>, plus <code>letsencrypt.org</code> to be safe</td>
</tr>
<tr>
<td>Squarespace</td>
<td><a href="https://support.squarespace.com/hc/en-us/articles/205815898-Understanding-SSL-certificates">Let's Encrypt</a></td>
<td>Not checked</td>
<td><code>letsencrypt.org</code></td>
</tr>
<tr>
<td>Blogger</td>
<td><a href="https://support.google.com/blogger/answer/1233387?hl=en">Requires</a> a record for <code>letsencrypt.org</code> if you use CAA</td>
<td>Not checked</td>
<td><code>letsencrypt.org</code></td>
</tr>
<tr>
<td>Wix</td>
<td><a href="https://support.wix.com/en/article/request-supporting-caa-records">CAA records are not supported</a>; remove them for a pointed domain</td>
<td>Not checked</td>
<td>No CAA record</td>
</tr>
</tbody></table>
<p>Three rows need more explanation. WordPress.com's support page says its certificates come from Let's Encrypt, and its own error text reads "This domain has CAA DNS records that do not allow Let's Encrypt to issue a certificate." But the current certificates for the custom domains I looked up, including <code>longreads.com</code>, were issued by Google Trust Services in July and September 2026. Each of those certificates covers 42 or 43 names, including <code>tls.automattic.com</code>, which matches the batching the same support page describes. I found no current Let's Encrypt certificate for those names. Until the docs and the logs agree, allowing both <code>letsencrypt.org</code> and <code>pki.goog</code> is the record that works either way.</p>
<p>Substack doesn't document which CA it uses. Its custom-domain target has no CAA records, so the lookup comes back to your zone, and the Google Trust Services certificates on <code>www.slowboring.com</code> from August 2026 show which CA is in use for that domain now. The target resolves to Cloudflare addresses, which suggests it can switch among Cloudflare's CAs. That is an inference, and it's why I'd add <code>letsencrypt.org</code> too.</p>
<p>Ghost(Pro) is the case I can't resolve from public sources. A <code>www</code> subdomain is covered by the CAA on the <code>ghost.io</code> target, and the certificate on <code>www.404media.co</code> was issued by Certainly, Fastly's CA, in September 2026. For an apex on Ghost's A record, Ghost doesn't say which CA it uses, so the safe move is to ask Ghost support before restricting the apex, or to leave it unrestricted.</p>
<h2>Write the CAA record</h2>
<p>CAA authorizations are additive, so you add one <code>issue</code> line per CA and keep the CAs you already rely on for other names under the same domain. A zone with a WordPress.com blog at the apex, following the table above, would look like this:</p>
<pre><code>example.com.  3600  IN  CAA  0 issue "letsencrypt.org"
example.com.  3600  IN  CAA  0 issue "pki.goog"
example.com.  3600  IN  CAA  0 iodef "mailto:security@example.com"
</code></pre>
<p>The <code>iodef</code> line is optional. It tells CAs where to report certificate requests that violate your policy. <code>issuewild</code> applies only to wildcard certificates, and when one is present, the <code>issue</code> lines are ignored for wildcards. Adding <code>issuewild ";"</code> blocks wildcards entirely. Before you add it, confirm that nothing in the zone, including a CDN in front of another site, relies on a wildcard certificate.</p>
<p>Records you place on a subdomain override the apex for that subdomain and everything below it. That's how you allow a host for <code>blog.example.com</code> without loosening the policy on <code>example.com</code>. The opposite also happens, and it's the usual way a stale record breaks a new host: a record added years ago at the apex for one CA now covers every subdomain that has no record of its own.</p>
<p>The <code>accounturi</code> parameter from <a href="https://www.rfc-editor.org/rfc/rfc8657.html">RFC 8657</a> restricts issuance to one ACME account. Netlify publishes its Let's Encrypt account URI for exactly this use, and none of the other hosts in the table document one. An <code>accounturi</code> the CA doesn't recognize makes the line unsatisfiable, so only add one your host has published.</p>
<p>If you use Cloudflare DNS with Universal SSL turned on, Cloudflare <a href="https://developers.cloudflare.com/ssl/edge-certificates/caa-records/">adds CAA records automatically</a> for its own CAs as soon as you add any CAA record to the zone. Those records don't appear in the dashboard. Check with <code>dig</code> to see the full set a CA receives.</p>
<h2>When the CAA lookup itself fails</h2>
<p>A correct record still won't help if CAs can't read it. Under the <a href="https://cabforum.org/working-groups/server/baseline-requirements/documents/">CA/Browser Forum Baseline Requirements</a>, CAs have checked CAA from several network locations since 2025. Since March 15, 2026, they must also perform DNSSEC validation on CAA lookups, and a validation error such as SERVFAIL "MUST NOT be treated as permission to issue." A zone with broken DNSSEC, often left over from changing DNS providers without removing the old DS record at the registrar, can now block issuance even with no CAA record at all. If <code>dig</code> returns SERVFAIL for your CAA query, fix DNSSEC before changing any records.</p>
<p>Resolvers keep serving your old CAA record until its TTL runs out. Lower the TTL before you edit, then wait at least that long before you ask the host to retry the certificate.</p>
<h2>When deleting the CAA record is the better fix</h2>
<p>If nobody can say why a CAA record exists, deleting it is a legitimate answer. With no CAA anywhere in the tree, any public CA can issue for the name, which is the default for most domains. CAA earns its place when you've decided which CAs may issue for your domain and you watch the <code>iodef</code> reports or <a href="https://sslmate.com/certspotter/">Certificate Transparency alerts</a>. A record copied from an old tutorial and forgotten gives you none of that, and it will break the next host move.</p>
<p>I learned most of this while wiring custom domains on <a href="https://justblogged.com">JustBlogged</a>. Certificates there come from Let's Encrypt once DNS points at the host, so a restricted zone only needs <code>letsencrypt.org</code> allowed.</p>
]]></content:encoded></item><item><title><![CDATA[Building a Remote MCP Server That Can Write to a Live Blog: Auth, Scopes, Tools and Images]]></title><description><![CDATA[Building a remote MCP server for publishing: OAuth 2.1 with PKCE, client registration, scopes, tool annotations, base64 images and drafts first.]]></description><link>https://usamaejaz.hashnode.dev/building-a-remote-mcp-server-that-can-write-to-a-live-blog-auth-scopes-tools-and-images</link><guid isPermaLink="true">https://usamaejaz.hashnode.dev/building-a-remote-mcp-server-that-can-write-to-a-live-blog-auth-scopes-tools-and-images</guid><category><![CDATA[Web Development]]></category><category><![CDATA[mcp]]></category><category><![CDATA[oauth]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[Usama Ejaz]]></dc:creator><pubDate>Sun, 11 Oct 2026 12:26:10 GMT</pubDate><content:encoded><![CDATA[<p>I spent the last few months building a remote MCP server for the blog platform I run (<a href="https://justblogged.com">JustBlogged</a>). It lets Claude, Cursor or Claude Code create posts, set tags and SEO fields, upload images and publish</p>
<p>Reading data over MCP is easy. Writing to something that is public on the internet is where every small decision starts to matter. Below is what I decided, what I'm still unsure about, and what I'd do differently</p>
<h2>Auth: OAuth instead of an API key in a config file</h2>
<p>The first version of most MCP servers I looked at was an API key pasted into a JSON config. It works. It also means a long-lived secret with full account access sitting in a dotfile, often synced, sometimes committed</p>
<p>I went with what the MCP authorization spec describes: OAuth 2.1 with PKCE. The flow in practice:</p>
<ol>
<li><p>The client calls a tool without a token and gets a <code>401</code> with a <code>WWW-Authenticate</code> header. That header carries <code>resource_metadata</code> (RFC 9728) and the scope to ask for</p>
</li>
<li><p>The client reads the protected resource metadata, finds the authorization server, then reads its metadata (RFC 8414)</p>
</li>
<li><p>It checks that <code>code_challenge_methods_supported</code> includes <code>S256</code>. The spec says a client should refuse to continue without PKCE</p>
</li>
<li><p>It gets a client ID, either by registering itself at runtime (Dynamic Client Registration, RFC 7591) or by using a Client ID Metadata Document URL, which the 2025-11-25 revision added</p>
</li>
<li><p>The browser opens, the user logs in and approves, and the code comes back to a loopback redirect like <code>http://127.0.0.1:33418/callback</code></p>
</li>
<li><p>The client exchanges the code plus the PKCE verifier for an access token and a refresh token, with a <code>resource</code> parameter (RFC 8707) so the token is bound to this one server</p>
</li>
</ol>
<p>From the user side that's "paste a URL, log in". No secret to copy anywhere</p>
<p>Dynamic registration has a cost I didn't think about at first. Every client instance registers, so the server ends up with a table of registrations and knows nothing about who is behind a given <code>client_id</code>. Metadata documents fix that by tying the client ID to a domain the client developer controls. I support both, because not every client does the newer one yet</p>
<p>Two smaller things that took me longer than they should have. The metadata URL is the well-known prefix plus the endpoint path (<code>/.well-known/oauth-protected-resource/mcp</code>), not just the root. And I left <code>initialize</code> and <code>tools/list</code> public on purpose, so anyone can see every tool schema before signing in. I think that's the right call for a write API... you should know what an agent will be able to do before you hand it a token</p>
<h2>Scope design: one scope that covers delete</h2>
<p>This is the part I'm least happy with</p>
<p>There is exactly one scope, <code>blogs:manage</code>. A token with it can do whatever the signed-in account can do, across every blog the account can manage: read, publish, delete posts, change domains, edit themes. Normal account permissions still apply, so a team member who can't delete a blog in the dashboard can't do it over MCP either</p>
<p>Why one scope. It keeps the consent screen honest and short, and every client gets the same capabilities, so I'm not debugging "works in Cursor, not in Claude" because of scope negotiation</p>
<p>The cost is real though. You can't give an agent read-only access to audit your archive. You can't give it write access to one blog and not the other three. If you want it to fix a typo, it gets the same token that can delete the blog</p>
<p>The obvious split is read vs write. I'm not sure that's enough. Fixing a typo in a draft and deleting a blog are both "write". Maybe it's read / write drafts / publish / destructive. Maybe it's per blog. And every extra scope is one more checkbox on a consent screen people click through without reading</p>
<p>Still not decided</p>
<h2>Tool design and annotations</h2>
<p>MCP tools can carry annotations like <code>readOnlyHint</code>, <code>destructiveHint</code> and <code>idempotentHint</code>. Clients use them to decide when to ask the user before running a call. They're hints, not enforcement, but a client that trusts them will auto-approve what you mark as safe</p>
<p>I got some of these wrong in the first version and had to go back through every tool. The questions I ended up asking per tool:</p>
<ul>
<li><p>Can this make something disappear or overwrite content someone can't get back? Then <code>destructiveHint: true</code>. Deletes obviously, but also some updates</p>
</li>
<li><p>If the agent retries the same call with the same arguments, does it end in the same state? An update that sets fields to given values usually is. A create is not, because a retry makes a second post. That's <code>idempotentHint</code></p>
</li>
<li><p>Does it only read? Then say so, so clients don't nag the user on every list call</p>
</li>
</ul>
<p>Agents retry. A timeout in the middle of a create is exactly when a client will call it again, so I think idempotency matters more here than in a normal API where a human clicks a button once</p>
<p>The other tool design choice that mattered: small, boring tools instead of a few clever ones. <code>list_blogs</code> returns the <code>blogId</code> every other tool needs. <code>create_post</code> requires only <code>blogId</code>, <code>title</code> and <code>content</code>, and everything else is optional. <code>get_post</code> reads back what the server actually stored. Server-side limits (10 tags, 50 characters per tag, 100 characters for a slug) get enforced in validation, not left to the model to remember</p>
<p>I also had a case where <code>create_post</code> failed on a fresh blog because it expected a collection to exist that a new user hadn't set up. Our bug. A human would figure that out from the dashboard. An agent just gets an error and guesses</p>
<h2>Images: base64 over MCP</h2>
<p><code>upload_image</code> takes the file as base64 in a <code>data</code> field, with <code>filename</code>, <code>mimeType</code> and the blog ID, up to 8 MB decoded. It returns a path you then set as the post image</p>
<p>The reason is boring. An MCP tool call is JSON-RPC, so arguments are JSON. There's no multipart body to stream a file into. The REST API takes a normal multipart upload, and a featured image can be set from a URL you already host</p>
<p>Base64 passes through the model's context though, so a big image burns a lot of tokens before it even reaches the server. Resizing first helps a lot. Upload-from-URL over MCP would fix most of it and I haven't built it yet</p>
<h2>Letting an agent touch a live blog</h2>
<p>The thing I worried about most is an agent publishing something half-finished to a real audience</p>
<p>So <code>status</code> on <code>create_post</code> defaults to <code>draft</code>. Publishing is a separate <code>update_post</code> call that sets <code>status</code> to <code>published</code>. I'd rather the agent need one extra explicit instruction to go public than have one missed instruction make it public</p>
<p>Some other things that help:</p>
<ul>
<li><p>Revisions are exposed as tools (<code>list_content_revisions</code>, <code>get_content_revision</code>), so if an agent overwrites a draft you edited by hand, the earlier version is still there</p>
</li>
<li><p>Reading back with <code>get_post</code> after every write. What the model says it did and what the server stored are not always the same thing</p>
</li>
<li><p>Content goes in as HTML, which means code blocks with a raw <code>&lt;div&gt;</code> inside them can turn into real markup if the conversion is sloppy. A draft you look at rendered catches that. A direct publish doesn't</p>
</li>
</ul>
<p>What I haven't solved is the delete case. With one scope, <code>delete_post</code> is available to any connected client, and the only real guard is the client asking before a destructive call (which depends on the annotations being right) and the user actually reading the prompt</p>
<p>Not sure how many ppl want an agent anywhere near a live blog in the first place. If you've built a write-capable MCP server, how did you split your scopes?</p>
<p>If you want to poke at it, the MCP endpoint is <a href="https://justblogged.com/mcp">https://justblogged.com/mcp</a> and tools/list works without an account</p>
]]></content:encoded></item></channel></rss>