Introduction#

This is another post in the series about comment systems for Hugo. In the previous one I set up the remark42 app itself on a VPS alongside the existing caddy-docker-proxy: Self-hosting remark42 alongside an existing Caddy on a VPS . The backend replies “pong”, the BoltDB database accepts writes. But those aren’t yet comments under posts. For them to appear on the page you need to wire in the widget: a piece of JavaScript that causes an iframe with a thread list and a form to be created.

My setup has one detail, though, that keeps coming back like a boomerang during this wiring. The blog and the remark42 instance sit on different domains. From the browser’s perspective, a remark42 iframe embedded on the page is a classic “third-party context”. The same category that ad pixels, “Log in with Facebook” buttons, and tracking scripts fall into.

Over the past five years, browsers have systematically restricted what an iframe in such a third-party context can do with cookies. Safari started in 2020 (Intelligent Tracking Prevention, ITP). Firefox joined in 2022 (Total Cookie Protection). Chrome, on which a large share of traffic on a typical technical blog depends, rolled out its changes in 2024 (Tracking Protection based on the CHIPS mechanism: Cookies Having Independent Partitioned State). All three come down to the same mechanic: a cookie set by remark42 in an iframe embedded on example.com lands in a separate storage “drawer”, assigned to a specific (top-level page, iframe domain) pair. The same cookie set by remark42 visited directly lands in a different drawer. The iframe simply doesn’t see its own cookie.

In practice, after the first deploy it looked like this: the widget loaded, the “Sign in as Anonymous” form worked, an avatar appeared above the comment field. But when I clicked “Send”, the backend responded with 401 Not authorized. It wasn’t a CORS problem, a missing XSRF token, or too narrow an ALLOWED_HOSTS. I checked all those suspicions separately and ruled them out. The fault lay in that partitioned cookies drawer the iframe didn’t have access to: the session was established in one browser context, and the widget was trying to use it in another.

This post breaks the problem apart and shows the ways out. First, wiring in the widget on the Hugo side (partial, call in single.html, entry in hugo.toml). Then the login mechanic step by step: why the widget shows me as logged in but the POST goes without authorization. I walk through the solution variants and describe the one I ultimately settled on: switching remark42 from a cookie session to one based on a JWT in a header, through two flags in .env.

Deploying the widget in Hugo#

The comment system on this blog is driven by a single parameter in hugo.toml. The partial layouts/partials/comments.html checks its value and renders the matching widget. remark42 comes in as a fourth variant into the same structure. Below I show the three files that need to be prepared or modified.

1. Partial remark42.html#

We create layouts/partials/comments/remark42.html. From it we load embed.js from the remark42 instance and configure the widget through the global remark_config object. We take the locale from the language of the currently active version of the page, because the blog is bilingual and PL and EN have separate comment threads:

{{- $remark := .Site.Params.remark42 -}}
{{- $lang := .Site.Language.Lang | default "en" -}}

<div id="remark42"></div>
<script>
  var remark_config = {
    host: {{ $remark.host }},
    site_id: {{ $remark.siteId }},
    url: {{ .Permalink }},
    components: ['embed'],
    theme: 'dark',
    locale: {{ $lang }}
  };
  (function(c) {
    for (var i = 0; i < c.length; i++) {
      var d = document, s = d.createElement('script');
      s.src = remark_config.host + '/web/' + c[i] + '.js';
      s.defer = true;
      (d.head || d.body).appendChild(s);
    }
  })(remark_config.components);
</script>

The script loads with the defer attribute so it doesn’t block the HTML parser. The url: {{ .Permalink }} field pins the comment thread to a specific page address. Without it the widget would take location.href, which with differences in trailing slashes or the language prefix (/posts/ vs. /pl/posts/) can split the thread between two URLs leading to the same post.

2. Wiring into the post layout#

Comments render through layouts/_default/single.html. At the bottom of the post template, a call to the dispatcher is enough:

{{ partial "comments.html" . }}

3. Configuring hugo.toml#

The commentSystem parameter tells the dispatcher which widget to render. Connection details for the remark42 instance go into a separate [params.remark42] section:

[params]
  commentSystem = "remark42"

[params.remark42]
  host = "https://remark42.example.com"
  siteId = "blog"

After these three changes, hugo server renders the #remark42 container at the bottom of posts and pulls in the widget script. That’s it on the Hugo side. The rest of the problem, which I describe below, lives in the browser.

Cross-domain session: problem and solution#

The symptom described in the Introduction, 401 Not authorized after clicking “Send”, doesn’t reveal itself right away. The login popup works, after typing in a nickname the widget shows an avatar. The mismatch comes from two different browser contexts in which the session lives. The login popup is a window in which remark42 is top-level, and the session cookie lands in the partition assigned to its domain. The widget iframe lives in the partition assigned to the blog’s domain and does not see that cookie. The login status reaches the iframe through window.postMessage from the popup, so the UI updates, but the comment POST is sent from the iframe without the session cookie, and the backend responds with 401.

The solution is switching remark42 to a session carried in a header instead of a cookie. Two flags in .env:

AUTH_SAME_SITE=none
AUTH_SEND_JWT_HEADER=true

With AUTH_SEND_JWT_HEADER=true the backend returns the JWT in an X-JWT header instead of in Set-Cookie. The widget stores the token in the localStorage of its iframe and attaches it as an X-JWT header to subsequent requests. HTTP headers are not subject to cookie partitioning, so the problem goes away. Two constraints worth noting: remark42 must be at version 1.15.0 or newer (older ones have bug #1877, where the token doesn’t reach the widget), and this option does not work with OAuth. Logging in via GitHub or Google requires a redirect flow based on cookies. If OAuth is needed, the only path is a blog subdomain pointing at the VPS with remark42.

The admin set up in the previous post through ADMIN_SHARED_ID works in this mode without changes. The token with attrs.admin: true lands in the widget’s localStorage the same way a regular user’s token does, so the moderation UI (deleting, pinning, blocking) appears in the widget the same way.

Summary#

Wiring the remark42 widget into Hugo from the template side is three files: the remark42.html partial, the partial call in single.html, and a section in hugo.toml. That’s the simple and quick part. The real weight of this post lies in what the browser does with that setup.

The phase-out of third-party cookies means that every self-hosted comment system embedded cross-domain in 2026 faces an architectural choice:

  • Subdomain infrastructure (e.g., comments.example.com). The session is same-site from the browser’s perspective. The cost is changes in DNS, TLS, and reverse-proxy configuration. The only path if you need OAuth.
  • Header-based auth. The session moves from cookies to a JWT header and the widget’s localStorage. No OAuth, but no changes in topology either. Two flags in .env are enough.
  • No session at all. The philosophy Cusdis represents: every comment is a form for an admin to approve, no user exists between requests. Cross-domain stops mattering, because there’s no session to manage.

remark42 gives the first two options. If you are starting a new setup and anonymous auth is enough, header-based auth is the cheapest entry: two flags in .env and the widget works cross-domain without changes to the hosting topology.

References and further reading#