{"id":65,"date":"2026-10-09T15:19:10","date_gmt":"2026-10-09T07:19:10","guid":{"rendered":"https:\/\/hoho.live\/luda\/index.php\/2026\/10\/09\/how-nginx-adds-custom-css-to-authelia-when-asset_path-cannot\/"},"modified":"2026-10-09T15:19:10","modified_gmt":"2026-10-09T07:19:10","slug":"how-nginx-adds-custom-css-to-authelia-when-asset_path-cannot","status":"publish","type":"post","link":"https:\/\/hoho.live\/luda\/index.php\/2026\/10\/09\/how-nginx-adds-custom-css-to-authelia-when-asset_path-cannot\/","title":{"rendered":"How nginx adds custom CSS to Authelia when asset_path cannot"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\"><strong>Short answer:<\/strong> Authelia\u2019s <code>server.asset_path<\/code> replaces <code>favicon.ico<\/code>, <code>logo.png<\/code>, and locale JSON. It has no stylesheet. The portal\u2019s default <strong>Content-Security-Policy<\/strong> is <code>style-src 'self' 'nonce-${NONCE}'<\/code>, so an off-origin CSS URL is refused, and an injected <code>&lt;style&gt;<\/code> cannot see the per-request nonce. The theme belongs on the reverse proxy: <strong>nginx <code>sub_filter<\/code><\/strong> inserts a same-origin <code>&lt;link&gt;<\/code>, and that file overrides the portal\u2019s CSS variables. Those files are not in the Authelia image, so an image upgrade does not delete them.<\/p>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n<h4 class=\"wp-block-heading\"><strong>What the login page actually sends<\/strong><\/h4>\n\n<p class=\"wp-block-paragraph\">The first response is HTML: a <code>&lt;\/head&gt;<\/code>, one bundled stylesheet, and <code>data-theme<\/code> on the document (<code>light<\/code>, <code>dark<\/code>, <code>grey<\/code>, or <code>oled<\/code>). <code>theme: auto<\/code> follows <code>prefers-color-scheme<\/code>. Dark and OLED tokens set a light <code>--foreground<\/code> and a white <code>--custom-icon<\/code>. The sign-in glyph is an SVG filled with that variable.<\/p>\n\n\n<p class=\"wp-block-paragraph\">Unless you change <code>server.headers.csp_template<\/code>, the documented default is <code>style-src 'self' 'nonce-${NONCE}'<\/code>. <code>'self'<\/code> allows a stylesheet from the portal host. The nonce allows Authelia\u2019s own inline styles. <code>sub_filter<\/code> is a fixed-string replace, so it cannot copy that nonce onto a tag you insert.<\/p>\n\n\n<p class=\"wp-block-paragraph\">That rules out editing assets baked into the image, and it rules out a <code>&lt;style&gt;<\/code> block in the HTML you inject.<\/p>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n<h4 class=\"wp-block-heading\"><strong>What asset_path can and cannot do<\/strong><\/h4>\n\n<p class=\"wp-block-paragraph\">Authelia serves static files from an embedded filesystem. <code>server.asset_path<\/code> overlays a directory. The <a href=\"https:\/\/www.authelia.com\/reference\/guides\/server-asset-overrides\/\">Server Asset Overrides<\/a> guide lists three entries: <code>favicon.ico<\/code>, <code>logo.png<\/code>, and <code>locales\/&lt;lang&gt;\/&lt;namespace&gt;.json<\/code>. There is no CSS file in that list.<\/p>\n\n\n<p class=\"wp-block-paragraph\">So these do not theme the page:<\/p>\n\n\n<ul class=\"wp-block-list\">\n<li>A stylesheet dropped into the asset directory<\/li>\n<li><code>theme: light<\/code> as if a background image were a theme option \u2014 <code>theme<\/code> only selects the built-in token set<\/li>\n<li>Relaxing <code>csp_template<\/code> so a third-party theme can load. The server docs call that header security-critical and say it should almost never be configured<\/li>\n<\/ul>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n<h4 class=\"wp-block-heading\"><strong>The working path<\/strong><\/h4>\n\n<ol class=\"wp-block-list\">\n<li>Leave Authelia\u2019s HTML, <code>theme<\/code>, and CSP alone.<\/li>\n<li>On the proxy location for the portal, clear <code>Accept-Encoding<\/code>, set <code>proxy_buffering on<\/code>, and replace the first <code>&lt;\/head&gt;<\/code> with a same-origin stylesheet link. <code>nginx -V<\/code> must show <code>--with-http_sub_module<\/code> (the module is not in every build). <code>sub_filter<\/code> does not edit a gzipped body, and it does not edit an unbuffered one.<\/li>\n<li>Serve that CSS, and any images it references, from the same host. Cache the file, and change the query string on the <code>&lt;link&gt;<\/code> when the CSS changes.<\/li>\n<li>In the stylesheet, set <code>color-scheme: light<\/code> and override the tokens the portal reads \u2014 <code>--background<\/code>, <code>--foreground<\/code>, <code>--card<\/code>, <code>--custom-icon<\/code>, <code>--primary<\/code>, and the matching foreground and border tokens \u2014 on <code>:root<\/code> and on each <code>[data-theme]<\/code>, with <code>!important<\/code>. Paint <code>html<\/code>, <code>body<\/code>, and <code>.bg-background<\/code>, because the shell covers the body. Hang a corner drawing on <code>body::before<\/code> (<code>position: fixed<\/code>, <code>pointer-events: none<\/code>) so it stays out of the centered card and does not take clicks.<\/li>\n<\/ol>\n\n<pre><code class=\"language-nginx\">proxy_set_header Accept-Encoding &quot;&quot;;\nproxy_buffering on;\nsub_filter '&lt;\/head&gt;' '&lt;link rel=&quot;stylesheet&quot; href=&quot;\/portal-theme\/portal.css&quot;&gt;&lt;\/head&gt;';\nsub_filter_once on;\n<\/code><\/pre>\n\n<p class=\"wp-block-paragraph\">Nothing in the directory has to change. <code>asset_path<\/code> can still replace the favicon and the logo beside this.<\/p>\n\n\n<p class=\"wp-block-paragraph\">This only restyles HTML the proxy returns for the portal. Applications behind forward-auth keep their own CSS. Responses that are not <code>text\/html<\/code> are not rewritten.<\/p>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n<h4 class=\"wp-block-heading\"><strong>Approaches that look simpler and fail<\/strong><\/h4>\n\n<figure class=\"wp-block-table\">\n<table>\n<thead>\n<tr>\n<th>Approach<\/th>\n<th>Why it fails if the portal must keep the default CSP<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>CSS under <code>asset_path<\/code><\/td>\n<td>The documented overrides are the favicon, the logo, and locale JSON<\/td>\n<\/tr>\n<tr>\n<td>An inline <code>&lt;style&gt;<\/code> via <code>sub_filter<\/code><\/td>\n<td>The nonce is per request, and <code>sub_filter<\/code> cannot copy it<\/td>\n<\/tr>\n<tr>\n<td>A third-party theme URL (the theme-park nginx snippet)<\/td>\n<td><code>style-src 'self'<\/code> refuses an off-origin stylesheet<\/td>\n<\/tr>\n<tr>\n<td>Override only <code>--background<\/code><\/td>\n<td>Dark tokens keep light text and a white <code>--custom-icon<\/code><\/td>\n<\/tr>\n<tr>\n<td>Leave gzip on, or <code>proxy_buffering off<\/code><\/td>\n<td><code>sub_filter<\/code> needs a buffered, uncompressed HTML body<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/figure>\n\n\n<p class=\"wp-block-paragraph\">A third-party theme is a reasonable look for a portal whose CSP you are willing to widen. It is the wrong tool when the default policy stays.<\/p>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n<h4 class=\"wp-block-heading\"><strong>When you can drop the proxy stylesheet<\/strong><\/h4>\n\n<p class=\"wp-block-paragraph\">Drop it when you give up \u201ca look the built-in themes do not have\u201d:<\/p>\n\n\n<ul class=\"wp-block-list\">\n<li>A logo, a favicon, or translated strings \u2014 <code>asset_path<\/code> is the supported hook<\/li>\n<li>The built-in light, dark, grey, or OLED theme<\/li>\n<li>A later Authelia release that documents a real stylesheet hook<\/li>\n<li>A deliberate edit to <code>csp_template<\/code> so off-origin CSS is allowed<\/li>\n<\/ul>\n\n\n<p class=\"wp-block-paragraph\">Those are product or policy changes, not a hidden switch in <code>theme<\/code>. Until one of them is true, keep the stylesheet on the proxy host, not in the container filesystem the next image pull replaces. After an upgrade, recheck only if the portal no longer emits <code>&lt;\/head&gt;<\/code>, <code>'self'<\/code> is gone from <code>style-src<\/code>, or the variable names move. The files themselves are still there.<\/p>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n<h4 class=\"wp-block-heading\"><strong>Why this stays published<\/strong><\/h4>\n\n<p class=\"wp-block-paragraph\">We used this on a homelab portal so the login page could carry the same paper ground as an internal page, without forking Authelia. The same gap shows up wherever a portal has no stylesheet hook and a nonce CSP. <strong>A custom Authelia login theme is a same-origin stylesheet on the reverse proxy. It is not an <code>asset_path<\/code> file, and replacing the Authelia image does not override it.<\/strong><\/p>\n\n\n<p class=\"wp-block-paragraph\">\u672c\u6587\u7531 HoHo \u8207 AI \u5354\u4f5c\u6574\u7406\uff0c\u6700\u5f8c\u66f4\u65b0\u65bc 2026 \u5e74 10 \u6708 9 \u65e5\u3002<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Short answer: Authelia\u2019s server.asset_path replaces favicon.ico, logo.png, and locale JSON. It has no stylesheet. The portal\u2019s default Content-Security-Policy is style-src &#8216;self&#8217; &#8216;nonce-${NONCE}&#8217;, so an off-origin CSS URL is refused, and an injected &lt;style&gt; cannot see the per-request nonce. The theme belongs on the reverse proxy: nginx sub_filter inserts a&hellip;<\/p>\n","protected":false},"author":2,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[27,7,28],"tags":[],"class_list":["post-65","post","type-post","status-publish","format-standard","hentry","category-authelia","category-homelab","category-nginx"],"_links":{"self":[{"href":"https:\/\/hoho.live\/luda\/index.php\/wp-json\/wp\/v2\/posts\/65","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/hoho.live\/luda\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/hoho.live\/luda\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/hoho.live\/luda\/index.php\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/hoho.live\/luda\/index.php\/wp-json\/wp\/v2\/comments?post=65"}],"version-history":[{"count":0,"href":"https:\/\/hoho.live\/luda\/index.php\/wp-json\/wp\/v2\/posts\/65\/revisions"}],"wp:attachment":[{"href":"https:\/\/hoho.live\/luda\/index.php\/wp-json\/wp\/v2\/media?parent=65"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/hoho.live\/luda\/index.php\/wp-json\/wp\/v2\/categories?post=65"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/hoho.live\/luda\/index.php\/wp-json\/wp\/v2\/tags?post=65"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}