<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Security and SSO &#8211; The Self-Host Journal</title>
	<atom:link href="https://selfhostjournal.eu/category/security-sso/feed/" rel="self" type="application/rss+xml" />
	<link>https://selfhostjournal.eu</link>
	<description>Real setups, real fixes</description>
	<lastBuildDate>Fri, 23 Oct 2026 19:18:45 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.3</generator>

<image>
	<url>https://selfhostjournal.eu/wp-content/uploads/2026/10/site-icon-terminal-150x150.png</url>
	<title>Security and SSO &#8211; The Self-Host Journal</title>
	<link>https://selfhostjournal.eu</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Traefik GeoBlock locked out my own AI assistant: allowing a published IP range</title>
		<link>https://selfhostjournal.eu/traefik-geoblock-allowed-ip-range/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Fri, 23 Oct 2026 19:18:45 +0000</pubDate>
				<category><![CDATA[Security and SSO]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=165</guid>

					<description><![CDATA[After switching on GeoBlock, Claude's connector to Home Assistant stopped working while everything else was fine. The logs showed why, and one allowedIPAddresses entry fixed it.]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-group dn-summary has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="dn-label wp-block-paragraph">In short</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> After I switched on Traefik GeoBlock, Claude&#8217;s connector to Home Assistant stopped working, while Home Assistant itself was fine for everyone at home.</p>



<p class="wp-block-paragraph"><strong>Solution:</strong> The connector calls from Anthropic&#8217;s servers in the US, and GeoBlock only allowed my own countries. One <code>allowedIPAddresses</code> entry with the provider&#8217;s published IP range fixed it.</p>



<p class="wp-block-paragraph"><strong>Tested with:</strong> Traefik 3, GeoBlock plugin (PascalMinder) 0.3.8, Home Assistant with an MCP server for Claude</p>
</div>



<p class="wp-block-paragraph">In <a href="/traefik-hardening-geoblock-crowdsec-sso/">Hardening Traefik</a> I put GeoBlock in front of every service: only visitors from the countries my family lives in get through, everyone else gets a 403. It worked exactly as planned. A bit too well, as it turned out: it also locked out a visitor I wanted, my own AI assistant.</p>



<p class="wp-block-paragraph">This is a short story about a security measure that does its job, breaks something you need, and how to find out which one it was.</p>



<h2 class="wp-block-heading">1. The symptom</h2>



<p class="wp-block-paragraph">I use Claude with a connector to Home Assistant, so I can ask about sensors, automations and logs in plain language. Shortly after the hardening, the connector couldn&#8217;t reach Home Assistant anymore. Every attempt failed.</p>



<p class="wp-block-paragraph">At the same time, Home Assistant was perfectly fine: the dashboard loaded at home, the app on my phone worked, automations ran. Only this one client was affected.</p>



<h2 class="wp-block-heading">2. What I checked first (and why it misled me)</h2>



<p class="wp-block-paragraph">A connector that signs in with OAuth has plenty of parts that can break, so that&#8217;s where you look first:</p>



<ul class="wp-block-list">
<li><strong>Sign-in and tokens:</strong> an expired token or a broken OAuth setup would explain a failing client. But reconnecting doesn&#8217;t help when the requests are blocked before they arrive.</li>



<li><strong>Home Assistant logs:</strong> nothing. No failed login, no error from the MCP integration, not even a request.</li>



<li><strong>The URL:</strong> it opens fine in the browser.</li>
</ul>



<p class="wp-block-paragraph">The empty Home Assistant log was the real clue, and it&#8217;s easy to read it the wrong way. &#8220;No errors&#8221; sounded like &#8220;nothing wrong in Home Assistant&#8221;. It actually meant: the requests never arrived. Something in front of Home Assistant was answering them. And &#8220;it works in my browser&#8221; proved nothing, because my browser comes from an allowed country.</p>



<h2 class="wp-block-heading">3. The real cause: Traefik GeoBlock did its job</h2>



<p class="wp-block-paragraph">Claude&#8217;s connectors don&#8217;t run in my browser or on my phone. They call my server from Anthropic&#8217;s infrastructure, which sits in the US. For GeoBlock that&#8217;s just another request from a country that isn&#8217;t on the list.</p>



<p class="wp-block-paragraph">Two logs show it. GeoBlock writes every denied request to its own file. A line looks like this (example address):</p>



<pre class="wp-block-code"><code>INFO: GeoBlock: 2026/10/16 09:14:52 geoblock@docker: request denied [203.0.113.17] for country [US] due to: country is not allowed</code></pre>



<p class="wp-block-paragraph">And the Traefik access log shows the 403 with an empty backend (<code>"-"</code> after the router name). That dash means the request never reached the service (again an example line):</p>



<pre class="wp-block-code"><code>203.0.113.17 - - [16/Oct/2026:09:14:52 +0000] "POST /api/webhook/… HTTP/1.1" 403 0 "-" "-" 4711 "homeassistant@docker" "-" 1ms</code></pre>



<p class="wp-block-paragraph">The log is full of scanners from all over the world, so the connector&#8217;s requests don&#8217;t stand out by themselves. Compare the denied addresses with the range Anthropic publishes for its outgoing traffic, and they do.</p>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tip:</strong> when one client fails and the app&#8217;s own log is empty, look at the reverse proxy first. A 403 with no backend in the access log is the typical sign of a middleware saying no.</p>
</div>



<h2 class="wp-block-heading">4. The fix: allow the published IP range</h2>



<p class="wp-block-paragraph">GeoBlock has an option for exactly this: <code>allowedIPAddresses</code>. Addresses in this list are let through no matter which country they come from. One more label on the Traefik container:</p>



<pre class="wp-block-code"><code>      # Claude connectors call from Anthropic's published outbound range
      # source: https://platform.claude.com/docs/en/api/ip-addresses
      - "traefik.http.middlewares.geoblock.plugin.geoblock.allowedIPAddresses[0]=160.79.104.0/21"</code></pre>



<p class="wp-block-paragraph">Then recreate the container so Traefik reads the new labels:</p>



<pre class="wp-block-code"><code>docker compose up -d traefik</code></pre>



<p class="wp-block-paragraph">The range above is the one Anthropic lists at the time of writing. Check <a href="https://platform.claude.com/docs/en/api/ip-addresses" target="_blank" rel="noreferrer noopener">Anthropic&#8217;s IP address page</a> for the current one before you copy it. The same approach works for any other service that has to reach you from abroad, as long as its provider publishes its IP ranges.</p>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Warning:</strong> only allow ranges the provider publishes officially. Never add an IP you found in your own log just because it looks right, and never open a whole country for one service. Also keep in mind what an exception means: this range is shared by all of the provider&#8217;s customers, not just you. GeoBlock lets them through, so the login of the service behind it still has to hold. In my case Home Assistant&#8217;s own sign-in and CrowdSec are still in place.</p>
</div>



<h2 class="wp-block-heading">5. How to verify</h2>



<ol class="wp-block-list">
<li>Check that Traefik picked up the label:<br><code>docker inspect traefik --format '{{ index .Config.Labels "traefik.http.middlewares.geoblock.plugin.geoblock.allowedIPAddresses[0]" }}'</code></li>



<li>Use the connector once (in my case: ask Claude for a sensor value).</li>



<li>Make sure the range no longer shows up as denied:<br><code>grep -E '\[160\.79\.1(0[4-9]|1[01])\.' traefik-logs/geoblock.log | tail</code><br>No new lines after the restart means GeoBlock lets the connector through.</li>
</ol>



<h2 class="wp-block-heading">What I learned</h2>



<ul class="wp-block-list">
<li>&#8220;Works in my browser&#8221; is no test for a geo-block. Ask where the failing client really calls from.</li>



<li>An empty application log is a finding, not a dead end: the request was stopped earlier.</li>



<li>Before switching on an allow list, write down every service that calls in from outside: AI connectors, webhooks, monitoring, push services. Each one needs a deliberate exception or a different path.</li>
</ul>



<p class="wp-block-paragraph">The full GeoBlock and CrowdSec setup is in <a href="/traefik-hardening-geoblock-crowdsec-sso/">Hardening Traefik: geo-blocking, CrowdSec and single sign-on</a>.</p>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Authentik: &#8220;The request is otherwise malformed&#8221; after creating a provider through the API</title>
		<link>https://selfhostjournal.eu/authentik-request-otherwise-malformed/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Fri, 23 Oct 2026 19:07:29 +0000</pubDate>
				<category><![CDATA[Security and SSO]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=160</guid>

					<description><![CDATA[Every login through a new Authentik provider failed with invalid_request. The cause was one missing field in the API call, and the browser error points the wrong way.]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-group dn-summary has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="dn-label wp-block-paragraph">In short</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> Every login through a new Authentik OAuth2/OpenID provider fails with <code>invalid_request</code>: &#8220;The request is otherwise malformed&#8221;.</p>



<p class="wp-block-paragraph"><strong>Solution:</strong> The provider was created through the API, and its <code>grant_types</code> list was empty. Set it to <code>authorization_code</code> and <code>refresh_token</code>, then test the authorize URL with curl.</p>



<p class="wp-block-paragraph"><strong>Tested with:</strong> Authentik 2026.8, WordPress with the OpenID Connect Generic plugin 3.11</p>
</div>



<p class="wp-block-paragraph">I add most apps to Authentik through the admin interface. For this blog I wanted to script it, so I created the OAuth2 provider with a single API call. The call succeeded, the application showed up, the WordPress plugin was configured with the right client ID, secret and endpoints. And then not a single login worked.</p>



<p class="wp-block-paragraph">The fix turned out to be one field. Finding it took longer than it should have, because the error message points in the wrong direction.</p>



<h2 class="wp-block-heading">1. The symptom: &#8220;request is otherwise malformed&#8221;</h2>



<p class="wp-block-paragraph">Clicking &#8220;Login with OpenID Connect&#8221; in WordPress sent me to Authentik, and Authentik sent me straight back with an error page instead of a login form:</p>



<pre class="wp-block-code"><code>invalid_request
The request is otherwise malformed</code></pre>



<p class="wp-block-paragraph">No username prompt, no password prompt. Authentik refused the request before the login flow even started.</p>



<h2 class="wp-block-heading">2. What I checked first (and why it misled me)</h2>



<p class="wp-block-paragraph">&#8220;Malformed request&#8221; sounds like the client sent something wrong. So I went through the usual suspects on the WordPress side:</p>



<ul class="wp-block-list">
<li><strong>Redirect URI:</strong> character by character against the provider. Authentik uses strict matching, so a missing slash or a different query string is enough to break it. It matched.</li>



<li><strong>Client ID:</strong> copied again, no stray spaces.</li>



<li><strong>Scopes:</strong> <code>openid email profile</code>, all three available in the provider.</li>



<li><strong>Endpoints:</strong> taken from the provider&#8217;s <code>.well-known/openid-configuration</code>.</li>
</ul>



<p class="wp-block-paragraph">Everything was correct. The request from WordPress was fine. The problem was on the Authentik side, and the browser error doesn&#8217;t tell you that.</p>



<h2 class="wp-block-heading">3. The real cause: an empty grant_types list</h2>



<p class="wp-block-paragraph">The Authentik server log had the more useful message. With Docker Compose:</p>



<pre class="wp-block-code"><code>docker compose logs server --since 10m | grep -i grant</code></pre>



<p class="wp-block-paragraph">It said &#8220;Invalid grant_type for provider&#8221;. Every OAuth2 provider in Authentik has a list of allowed grant types. When you create a provider in the admin interface, the form fills in sensible defaults. The API doesn&#8217;t: <code>grant_types</code> is an optional field, and if you leave it out, the provider is saved with an <strong>empty list</strong>. An empty list means no grant type is allowed, so not even the normal authorization code flow works.</p>



<p class="wp-block-paragraph">My API call looked roughly like this (shortened):</p>



<pre class="wp-block-code"><code>curl -X POST https://id.example.com/api/v3/providers/oauth2/ \
  -H "Authorization: Bearer $AUTHENTIK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Provider for My Blog",
    "client_type": "confidential",
    "authorization_flow": "&lt;flow uuid&gt;",
    "invalidation_flow": "&lt;flow uuid&gt;",
    "redirect_uris": [
      {"matching_mode": "strict",
       "url": "https://blog.example.com/wp-admin/admin-ajax.php?action=openid-connect-authorize"}
    ],
    "signing_key": "&lt;certificate uuid&gt;",
    "property_mappings": ["&lt;openid&gt;", "&lt;email&gt;", "&lt;profile&gt;"]
  }'</code></pre>



<p class="wp-block-paragraph">Every required field is there, so the API accepts it without a warning. The one field that decides whether anyone can log in is the one that&#8217;s missing.</p>



<h2 class="wp-block-heading">4. The fix</h2>



<p class="wp-block-paragraph">Set the grant types on the existing provider. A login flow for a website needs <code>authorization_code</code>, and <code>refresh_token</code> if the app renews its tokens:</p>



<pre class="wp-block-code"><code>curl -X PATCH https://id.example.com/api/v3/providers/oauth2/&lt;provider id&gt;/ \
  -H "Authorization: Bearer $AUTHENTIK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"grant_types": ["authorization_code", "refresh_token"]}'</code></pre>



<p class="wp-block-paragraph">You can also open the provider in the admin interface and save it there with both grant types ticked. For new providers, add the line straight to the create call:</p>



<pre class="wp-block-code"><code>    "grant_types": ["authorization_code", "refresh_token"],</code></pre>



<p class="wp-block-paragraph">While you&#8217;re at it, compare the token lifetimes too (<code>access_token_validity</code>, <code>refresh_token_threshold</code>) with a provider you created in the interface. API-created providers don&#8217;t always get the same values, and it&#8217;s easier to keep all providers alike.</p>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Warning:</strong> only allow the grant types the app really uses. <code>password</code> and <code>client_credentials</code> skip the login flow, and with it your MFA and policies. A website login doesn&#8217;t need them.</p>
</div>



<h2 class="wp-block-heading">5. How to verify (without a browser)</h2>



<p class="wp-block-paragraph">You don&#8217;t need to click through the login to test a provider. Call the authorize endpoint with curl and look at where Authentik sends you:</p>



<pre class="wp-block-code"><code>curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' \
  "https://id.example.com/application/o/authorize/?client_id=&lt;client id&gt;&amp;response_type=code&amp;scope=openid&amp;redirect_uri=https%3A%2F%2Fblog.example.com%2Fwp-admin%2Fadmin-ajax.php%3Faction%3Dopenid-connect-authorize"</code></pre>



<ul class="wp-block-list">
<li><strong>Working:</strong> a <code>302</code> to Authentik&#8217;s own login flow, for example <code>/if/flow/default-authentication-flow/…</code>.</li>



<li><strong>Broken:</strong> a <code>302</code> back to your app&#8217;s redirect URI with <code>error=…</code> in the URL. The error name tells you what Authentik didn&#8217;t like.</li>
</ul>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tip:</strong> the redirect URI in the curl call must be URL-encoded and match the provider exactly, otherwise you test the wrong problem. I now run this check after every provider I create, whether through the API or the interface. It takes five seconds.</p>
</div>



<h2 class="wp-block-heading">What I learned</h2>



<ul class="wp-block-list">
<li>&#8220;Malformed request&#8221; in the browser doesn&#8217;t mean the client is wrong. Read the identity provider&#8217;s server log before you change anything in the app.</li>



<li>The admin interface and the API don&#8217;t create the same object. The interface fills in defaults, the API stores exactly what you send.</li>



<li>A curl test of the authorize URL checks a provider in seconds, without logging in.</li>
</ul>



<p class="wp-block-paragraph">How the blog login is set up end to end is in <a href="/wordpress-sso-authentik/">Single sign-on for WordPress with Authentik</a>. All provider fields are described in the <a href="https://docs.goauthentik.io/" target="_blank" rel="noreferrer noopener">official Authentik documentation</a>.</p>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Hardening Traefik: geo-blocking, CrowdSec and single sign-on</title>
		<link>https://selfhostjournal.eu/traefik-hardening-geoblock-crowdsec-sso/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Fri, 23 Oct 2026 13:19:17 +0000</pubDate>
				<category><![CDATA[Homelab]]></category>
		<category><![CDATA[Security and SSO]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=134</guid>

					<description><![CDATA[Part 2 of my Traefik setup: GeoBlock for the countries you need, CrowdSec with its web application firewall, Authentik forward auth in front of apps, container basics and how to test it all.]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-group dn-summary has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="dn-label wp-block-paragraph">In short</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> As soon as ports 80 and 443 are open, bots from all over the world scan your services, try passwords and probe for known vulnerabilities. Traefik alone forwards all of it.</p>



<p class="wp-block-paragraph"><strong>Solution:</strong> Several layers in front of every service: geo-blocking for countries you never visit from, CrowdSec for known attackers and attack patterns, single sign-on in front of apps without their own good login, plus a few container basics.</p>



<p class="wp-block-paragraph"><strong>Tested with:</strong> Traefik 3.7, GeoBlock plugin 0.3.8, CrowdSec 1.8 with the Traefik bouncer plugin 1.7 and AppSec, Authentik 2026.8, Docker Compose on a small home server</p>
</div>



<p class="wp-block-paragraph">This is the second part of my Traefik setup. The <a href="/traefik-reverse-proxy-docker/">first part</a> explains the basics: entrypoints, routers, labels and certificates. Here it&#8217;s about the question Traefik itself doesn&#8217;t answer: should this request get in at all? A reverse proxy decides <em>where</em> a request goes. The layers in this article decide <em>whether</em>.</p>



<h2 class="wp-block-heading">The layers at a glance</h2>



<p class="wp-block-paragraph">Every request to port 443 passes these checks in order. Each one is cheap, and each one stops a different kind of visitor:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>#</th><th>Layer</th><th>Stops</th><th>Applies to</th></tr></thead><tbody><tr><td>1</td><td>GeoBlock</td><td>Everything from countries I don&#8217;t allow</td><td>all services</td></tr><tr><td>2</td><td>CrowdSec (IP reputation)</td><td>IP addresses known for attacks, and IPs that misbehave on my server</td><td>all services</td></tr><tr><td>3</td><td>CrowdSec AppSec (WAF)</td><td>Requests that look like attacks: SQL injection, path traversal, known exploits</td><td>all services</td></tr><tr><td>4</td><td>Single sign-on (Authentik)</td><td>Everyone who isn&#8217;t logged in</td><td>apps without their own good login</td></tr><tr><td>5</td><td>The app&#8217;s own login</td><td>Everyone without an account</td><td>each app</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">No single layer is perfect. GeoBlock is fooled by a VPN, CrowdSec doesn&#8217;t know every attacker, and every login can have a bug. Together, they turn &#8220;anyone on the internet&#8221; into &#8220;a few people, in a few countries, with a valid login&#8221;.</p>



<h2 class="wp-block-heading">1. One line for all services</h2>



<p class="wp-block-paragraph">You could add middlewares to every router separately. I attach the two global ones to the <code>websecure</code> entrypoint instead, in Traefik&#8217;s static configuration. That way no service can be forgotten, including the ones I add next year:</p>



<pre class="wp-block-code"><code>    command:
      # ... the configuration from part 1 ...

      # Security for EVERY site on 443: first GeoBlock, then CrowdSec, then security headers (step 5).
      # To switch both off quickly: comment out this line and run docker compose up -d
      - "--entrypoints.websecure.http.middlewares=geoblock@docker,crowdsec@docker,secheaders@docker"

      # The two plugins, downloaded from plugins.traefik.io at start
      - "--experimental.plugins.geoblock.modulename=github.com/PascalMinder/geoblock"
      - "--experimental.plugins.geoblock.version=v0.3.8"
      - "--experimental.plugins.crowdsec.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
      - "--experimental.plugins.crowdsec.version=v1.7.1"</code></pre>



<ul class="wp-block-list">
<li><strong>The order matters:</strong> GeoBlock runs first because it&#8217;s the cheapest check. Requests from blocked countries never reach CrowdSec, which keeps its logs and work small.</li>



<li><strong><code>@docker</code></strong> means the middleware is defined with Docker labels, here on the Traefik container itself (next sections).</li>



<li><strong>Plugin versions are pinned</strong>, for the same reason as the Traefik image in part 1: a new plugin version can change options.</li>



<li><strong>Port 80 is not covered,</strong> and that&#8217;s fine: it only answers with a redirect to HTTPS, which then passes every check.</li>
</ul>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tip:</strong> the comment above the line is deliberate. If you ever lock yourself out (for example while travelling), one commented-out line and a restart turn all filtering off. Write that down somewhere you&#8217;ll find it under stress.</p>
</div>



<h2 class="wp-block-heading">2. GeoBlock: only the countries you need</h2>



<p class="wp-block-paragraph">My services are used by my family, all of us in two European countries. There&#8217;s no reason for anyone in other countries to reach my login pages. GeoBlock looks up the country of each visitor&#8217;s IP address and only lets the allowed ones through. These labels go on the Traefik container:</p>



<pre class="wp-block-code"><code>    labels:
      # --- GeoBlock: only allow listed countries (+ private/home IPs) ---
      - "traefik.http.middlewares.geoblock.plugin.geoblock.countries[0]=DE"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.countries[1]=AT"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.allowLocalRequests=true"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.allowUnknownCountries=false"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.unknownCountryApiResponse=nil"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.blackListMode=false"

      # Exceptions: a service that has to reach you from abroad
      - "traefik.http.middlewares.geoblock.plugin.geoblock.allowedIPAddresses[0]=203.0.113.0/24"

      # Country lookup
      - "traefik.http.middlewares.geoblock.plugin.geoblock.api=https://get.geojs.io/v1/ip/country/{ip}"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.apiTimeoutMs=750"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.cacheSize=100"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.forceMonthlyUpdate=true"

      # Logging: only denied requests, into their own file
      - "traefik.http.middlewares.geoblock.plugin.geoblock.silentStartUp=false"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.logLocalRequests=false"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.logAllowedRequests=false"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.logApiRequests=false"
      - "traefik.http.middlewares.geoblock.plugin.geoblock.logFilePath=/var/log/traefik/geoblock.log"</code></pre>



<ul class="wp-block-list">
<li><strong><code>countries</code>:</strong> the allowed countries as two-letter codes. Mine are different; pick your own. With <code>blackListMode=false</code> this is an allow list: everything not listed is blocked. The opposite (block a few countries, allow the rest) protects much less.</li>



<li><strong><code>allowLocalRequests=true</code>:</strong> private addresses (your home network) are always allowed. Without it, you lock yourself out at home as soon as a request arrives with a local address.</li>



<li><strong><code>allowUnknownCountries=false</code>:</strong> if the lookup finds no country, the request is blocked. Safer, at the price of a rare false block.</li>



<li><strong><code>allowedIPAddresses</code>:</strong> exceptions for services that call you from another country. In my case that&#8217;s an AI assistant&#8217;s connector to Home Assistant, which comes from the provider&#8217;s published IP range in the US. Only allow ranges the provider publishes officially, never a guessed one.</li>



<li><strong>Lookup and cache:</strong> unknown IP addresses are looked up with an online service (<code>geojs.io</code> here), at most 750 ms. The last 100 results are cached, so repeat visitors don&#8217;t cause new lookups.</li>



<li><strong>Log file:</strong> every blocked request ends up in <code>geoblock.log</code>. It&#8217;s surprisingly instructive to read it after a day: most of it is automated scanning.</li>
</ul>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Think about this before you switch it on:</strong> you&#8217;ll be blocked yourself when you travel to another country, unless you connect through a VPN to your home network first. And the country lookup sends visitors&#8217; IP addresses to an external service. For a private homelab that only family uses, that&#8217;s fine for me; for a public website with visitors, it belongs in the privacy policy, or you use an offline country database instead.</p>
</div>



<p class="wp-block-paragraph">Let&#8217;s Encrypt still works with GeoBlock on. The TLS challenge from part 1 is answered by Traefik before any middleware runs, so the validation servers abroad aren&#8217;t blocked.</p>



<h2 class="wp-block-heading">3. CrowdSec: known attackers and attack patterns</h2>



<p class="wp-block-paragraph">CrowdSec does two things. It reads Traefik&#8217;s access log and notices when an IP address behaves like an attacker (many failed logins, scanning for <code>/wp-login.php</code> on a server without WordPress, and so on). And it shares this with a community list, so you also block addresses that attacked someone else yesterday. In addition, its AppSec component works as a web application firewall (WAF): it checks each request against rules for common attacks before Traefik forwards it.</p>



<p class="wp-block-paragraph">There are two parts: the CrowdSec engine (its own container) and a <em>bouncer</em> inside Traefik that asks the engine about every request.</p>



<h3 class="wp-block-heading">The engine</h3>



<pre class="wp-block-code"><code>  crowdsec:
    image: crowdsecurity/crowdsec:v1.8.1
    container_name: crowdsec
    restart: unless-stopped
    environment:
      # Detection rules: Traefik log parser, common web attacks, known CVEs, AppSec (WAF)
      COLLECTIONS: "crowdsecurity/traefik crowdsecurity/base-http-scenarios crowdsecurity/http-cve crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules"
      # Registers the Traefik bouncer with the key from .env
      BOUNCER_KEY_traefik: ${CROWDSEC_BOUNCER_KEY}
      TZ: Europe/Berlin
    volumes:
      - ./crowdsec/config:/etc/crowdsec
      - ./crowdsec/data:/var/lib/crowdsec/data
      - ./crowdsec/acquis.d:/etc/crowdsec/acquis.d:ro
      - ./traefik-logs:/var/log/traefik:ro
    networks:
      - proxy
    # No published ports: Traefik reaches it as crowdsec:8080 and crowdsec:7422</code></pre>



<ul class="wp-block-list">
<li><strong>Collections</strong> are rule bundles from the CrowdSec hub: a parser for Traefik&#8217;s log format, scenarios for typical web attacks and known vulnerabilities (CVEs), and the AppSec rule sets.</li>



<li><strong><code>BOUNCER_KEY_traefik</code></strong> registers Traefik as a bouncer with a key you choose. Create one and put it in the <code>.env</code> file next to the compose file, never in the compose file itself: <code>echo "CROWDSEC_BOUNCER_KEY=$(openssl rand -hex 32)" &gt;&gt; .env</code>.</li>



<li><strong>Traefik&#8217;s log folder</strong> is mounted read-only, so CrowdSec can read the access log from part 1.</li>



<li><strong>No published ports:</strong> only Traefik talks to CrowdSec, through the shared <code>proxy</code> network.</li>
</ul>



<p class="wp-block-paragraph">What CrowdSec reads is defined in <code>crowdsec/acquis.d/traefik.yaml</code>:</p>



<pre class="wp-block-code"><code># 1. Traefik access log: brute force, scans, known exploits
filenames:
  - /var/log/traefik/access.log
labels:
  type: traefik
---
# 2. AppSec (WAF): Traefik sends every request here before passing it on
source: appsec
name: appsec
listen_addr: 0.0.0.0:7422
appsec_config: crowdsecurity/appsec-default
labels:
  type: appsec</code></pre>



<h3 class="wp-block-heading">The bouncer in Traefik</h3>



<pre class="wp-block-code"><code>    labels:
      # --- CrowdSec bouncer ---
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.enabled=true"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecMode=stream"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecLapiScheme=http"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecLapiHost=crowdsec:8080"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecLapiKey=${CROWDSEC_BOUNCER_KEY}"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.updateIntervalSeconds=60"
      # AppSec (WAF)
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecAppsecEnabled=true"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecAppsecHost=crowdsec:7422"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecAppsecFailureBlock=true"
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.crowdsecAppsecUnreachableBlock=false"
      # never ban the home network
      - "traefik.http.middlewares.crowdsec.plugin.crowdsec.clientTrustedIPs[0]=192.168.10.0/24"</code></pre>



<ul class="wp-block-list">
<li><strong><code>crowdsecMode=stream</code>:</strong> the bouncer downloads the full list of blocked addresses every 60 seconds and checks requests locally. That&#8217;s fast, and Traefik doesn&#8217;t have to ask CrowdSec for each request.</li>



<li><strong><code>crowdsecAppsecFailureBlock=true</code>:</strong> if AppSec can&#8217;t decide about a request (an error in the check), the request is blocked.</li>



<li><strong><code>crowdsecAppsecUnreachableBlock=false</code>:</strong> if the CrowdSec container is down, requests are let through. That&#8217;s a deliberate trade-off: during a CrowdSec update, my services stay online and GeoBlock still filters. If you&#8217;d rather be offline than unprotected, set it to <code>true</code>.</li>



<li><strong><code>clientTrustedIPs</code>:</strong> your home network is never banned. Without it, a family member typing the wrong password five times can lock out the whole house.</li>
</ul>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tip:</strong> Docker Compose fills <code>${CROWDSEC_BOUNCER_KEY}</code> from the <code>.env</code> file in labels too, so the key appears in neither file you&#8217;d share or back up publicly. Keep <code>.env</code> out of any Git repository.</p>
</div>



<h3 class="wp-block-heading">Everyday commands</h3>



<pre class="wp-block-code"><code># which addresses are blocked right now, and why?
docker exec crowdsec cscli decisions list

# what was detected recently?
docker exec crowdsec cscli alerts list

# is the log being read, are the bouncer and AppSec talking to CrowdSec?
docker exec crowdsec cscli metrics

# unblock an address (e.g. a friend who got banned)
docker exec crowdsec cscli decisions delete --ip 198.51.100.7

# block an address by hand for 24 hours
docker exec crowdsec cscli decisions add --ip 198.51.100.7 --duration 24h</code></pre>



<p class="wp-block-paragraph">The access and GeoBlock logs grow every day, so I rotate them with logrotate on the host (7 days, compressed). <code>copytruncate</code> keeps the file in place, so neither Traefik nor CrowdSec needs a restart:</p>



<pre class="wp-block-code"><code># /etc/logrotate.d/traefik
/path/to/traefik/traefik-logs/*.log {
    daily
    rotate 7
    compress
    missingok
    notifempty
    copytruncate
}</code></pre>



<h2 class="wp-block-heading">4. Single sign-on in front of apps</h2>



<p class="wp-block-paragraph">Some apps have no login of their own, or a weak one: Traefik&#8217;s dashboard, the Zigbee2MQTT frontend, small admin tools. For those, Traefik asks my identity provider Authentik before every request: is this person logged in, and allowed to use this app? If not, they see Authentik&#8217;s login page, with two-factor authentication. This is called <em>forward auth</em>.</p>



<p class="wp-block-paragraph">The middleware is defined once, with labels on the Authentik server container:</p>



<pre class="wp-block-code"><code>    labels:
      # Embedded outpost path (needed for forward auth on other domains)
      - traefik.http.routers.authentik-outpost.rule=HostRegexp(`.+`) &amp;&amp; PathPrefix(`/outpost.goauthentik.io/`)
      - traefik.http.routers.authentik-outpost.priority=1000
      - traefik.http.routers.authentik-outpost.entrypoints=websecure
      - traefik.http.routers.authentik-outpost.tls=true
      - traefik.http.routers.authentik-outpost.service=authentik

      # Forward-auth middleware for apps WITHOUT their own login
      - traefik.http.middlewares.authentik.forwardauth.address=http://authentik-server:9000/outpost.goauthentik.io/auth/traefik
      - traefik.http.middlewares.authentik.forwardauth.trustForwardHeader=true
      - traefik.http.middlewares.authentik.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name,X-authentik-uid</code></pre>



<ul class="wp-block-list">
<li><strong>The outpost router</strong> catches the path <code>/outpost.goauthentik.io/</code> on <em>every</em> host name and sends it to Authentik. That&#8217;s where the login redirect comes back to. <code>priority=1000</code> makes sure it wins against the app&#8217;s own router.</li>



<li><strong><code>forwardauth.address</code>:</strong> Traefik asks this URL about every request. Authentik answers &#8220;yes&#8221; (the request continues) or with a redirect to the login page.</li>



<li><strong><code>authResponseHeaders</code>:</strong> after a successful login, Authentik passes the user name, groups and e-mail to the app as headers. Apps that understand them can log the user in automatically.</li>
</ul>



<p class="wp-block-paragraph">Protecting an app is then one more label on its router, here for the Traefik dashboard from part 1:</p>



<pre class="wp-block-code"><code>      - "traefik.http.routers.traefik.middlewares=authentik@docker"</code></pre>



<p class="wp-block-paragraph">In Authentik, each protected app needs a <em>Proxy Provider</em> in forward-auth mode (single application) and an application that is bound to the users or groups allowed in. Without that, Authentik doesn&#8217;t know the host name and refuses everyone.</p>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Not for every app:</strong> forward auth only works for browsers. Mobile apps and other clients that talk to an API (the Home Assistant app, password manager apps, sync clients) can&#8217;t show Authentik&#8217;s login page and simply stop working. For those apps I use their native single sign-on through OpenID Connect where they support it (I explained it for WordPress in <a href="/wordpress-sso-authentik/">this article</a>), and otherwise rely on their own login plus the layers above.</p>
</div>



<h2 class="wp-block-heading">5. Security headers for every service</h2>



<p class="wp-block-paragraph">Security headers are short instructions that the server sends to the browser with each page. Many apps set some of them, but rarely all, and every app does it differently. A third middleware on the entrypoint adds the important ones to every service and removes the headers that only tell attackers which software and version you run. These labels go on the Traefik container, and the entrypoint line from step 1 gets a third entry: <code>geoblock@docker,crowdsec@docker,secheaders@docker</code>.</p>



<pre class="wp-block-code"><code>    labels:
      # --- Security headers for every site on 443 ---
      # HSTS: browsers use https only, for 1 year
      - "traefik.http.middlewares.secheaders.headers.stsSeconds=31536000"
      - "traefik.http.middlewares.secheaders.headers.contentTypeNosniff=true"
      # Hide software names and versions (an empty value removes the header)
      - "traefik.http.middlewares.secheaders.headers.customResponseHeaders.Server="
      - "traefik.http.middlewares.secheaders.headers.customResponseHeaders.X-Powered-By="</code></pre>



<ul class="wp-block-list">
<li><strong>HSTS (<code>stsSeconds</code>):</strong> after the first visit, the browser refuses to open the site over plain HTTP for one year, even if someone tries to redirect it there in a public Wi-Fi. I left out <code>includeSubdomains</code> and <code>preload</code> on purpose: they also bind every other subdomain of your domain to HTTPS, and preload is very hard to undo.</li>



<li><strong><code>contentTypeNosniff</code>:</strong> the browser trusts the declared file type and doesn&#8217;t guess. That stops an uploaded &#8220;image&#8221; from being run as a script.</li>



<li><strong>Removing <code>Server</code> and <code>X-Powered-By</code>:</strong> before, my services told everyone things like <code>Server: nginx</code> or <code>X-Powered-By: PHP/8.5</code>. That doesn&#8217;t make anything vulnerable, but it saves an attacker the first step of looking up exploits for exactly that version.</li>
</ul>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Not every header belongs here:</strong> <code>X-Frame-Options</code> and a Content Security Policy depend on the app. My office suite is shown inside Nextcloud in a frame; a central &#8220;never show in a frame&#8221; header would break it. Those I leave to the apps, which mostly set them correctly anyway. After adding the middleware, open every service once and check that it still works.</p>
</div>



<p class="wp-block-paragraph">Checking takes one command per service. You should see <code>strict-transport-security</code> and <code>x-content-type-options</code>, and no <code>server</code> line:</p>



<pre class="wp-block-code"><code>curl -s -o /dev/null -D - https://app.example.com/ | grep -i -E "strict-transport|x-content-type|^server|x-powered"</code></pre>



<h2 class="wp-block-heading">6. Container basics</h2>



<p class="wp-block-paragraph">A few small settings on the containers themselves, which cost nothing:</p>



<pre class="wp-block-code"><code>  traefik:
    image: traefik:v3.7
    security_opt:
      - no-new-privileges:true
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      # ...</code></pre>



<ul class="wp-block-list">
<li><strong><code>no-new-privileges</code>:</strong> processes in the container can&#8217;t gain more rights later (for example through a setuid program). If someone ever breaks into Traefik, this makes the next step harder.</li>



<li><strong>Docker socket read-only:</strong> Traefik only needs to read container information. Be aware that <code>:ro</code> only protects the socket <em>file</em>; the Docker API behind it still accepts commands. A stricter solution is a socket proxy, a small container that only lets through the read requests Traefik needs. I use one for my monitoring portal and plan to switch Traefik to it too.</li>



<li><strong>No published ports</strong> on any service except Traefik (80 and 443). Everything else is only reachable through Traefik and therefore through all the layers above. A forgotten <code>ports:</code> line bypasses all of them.</li>



<li><strong><code>exposedbydefault=false</code></strong> from part 1 belongs here too: nothing is public by accident.</li>
</ul>



<h2 class="wp-block-heading">7. Testing that it works</h2>



<p class="wp-block-paragraph">Don&#8217;t trust a security setup you haven&#8217;t tested. These checks take five minutes. Run the attack tests from <em>outside</em> your home network (mobile data, not Wi-Fi), because your home network is on every allow list:</p>



<pre class="wp-block-code"><code># AppSec should block typical attack requests with 403
curl -s -o /dev/null -w "%{http_code}n" "https://app.example.com/?id=1%27%20OR%20%271%27=%271"
curl -s -o /dev/null -w "%{http_code}n" "https://app.example.com/../../etc/passwd"
curl -s -o /dev/null -w "%{http_code}n" "https://app.example.com/.env"

# did CrowdSec see it?
docker exec crowdsec cscli alerts list | head

# GeoBlock: requests from other countries show up here
tail -n 5 traefik-logs/geoblock.log

# forward auth: without a login you should be sent to Authentik (302)
curl -s -o /dev/null -w "%{http_code} %{redirect_url}n" https://traefik.example.com/</code></pre>



<p class="wp-block-paragraph">To test GeoBlock from another country, use a VPN with an exit in a blocked country, or one of the many online &#8220;check website from another location&#8221; services. You should get a <code>403</code>.</p>



<h2 class="wp-block-heading">What I don&#8217;t do (yet)</h2>



<ul class="wp-block-list">
<li><strong>Central frame and content policies</strong> (<code>X-Frame-Options</code>, Content Security Policy): they depend too much on each app, so the apps set them themselves (see step 5).</li>



<li><strong>Custom TLS options:</strong> not needed for me. I checked with <code>openssl s_client</code>: Traefik&#8217;s defaults already refuse TLS 1.0 and 1.1 and only accept 1.2 and 1.3.</li>



<li><strong>Rate limiting:</strong> Traefik&#8217;s <code>ratelimit</code> middleware on login pages. CrowdSec covers most of it by banning brute-force attempts.</li>



<li><strong>The socket proxy for Traefik,</strong> as mentioned above.</li>
</ul>



<h2 class="wp-block-heading">What I learned</h2>



<ul class="wp-block-list">
<li><strong>Entrypoint middlewares beat per-router middlewares:</strong> new services are protected from their first second, without me remembering anything.</li>



<li><strong>Allow lists beat block lists.</strong> Listing the two countries I need is safer and simpler than listing the hundred I don&#8217;t.</li>



<li><strong>Always exempt the home network</strong>, in GeoBlock and in CrowdSec. Otherwise the first person locked out is you.</li>



<li><strong>Decide what happens when a security layer fails:</strong> block everything, or let traffic through. Both are valid, but it should be a decision, not a default you never looked at.</li>



<li><strong>Test from outside.</strong> Everything looks secure from your own sofa, because your home network is on every allow list.</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Single sign-on for WordPress with Authentik</title>
		<link>https://selfhostjournal.eu/wordpress-sso-authentik/</link>
		
		<dc:creator><![CDATA[selfhostjournal]]></dc:creator>
		<pubDate>Thu, 22 Oct 2026 16:53:17 +0000</pubDate>
				<category><![CDATA[Security and SSO]]></category>
		<guid isPermaLink="false">https://selfhostjournal.eu/?p=29</guid>

					<description><![CDATA[Every setting on both sides: the Authentik provider and application, all values for the OpenID Connect Generic plugin, a curl test and a troubleshooting table.]]></description>
										<content:encoded><![CDATA[
<div class="wp-block-group dn-summary has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="dn-label wp-block-paragraph">In short</p>



<p class="wp-block-paragraph"><strong>Problem:</strong> WordPress was the last service still asking for its own password.</p>



<p class="wp-block-paragraph"><strong>Solution:</strong> An OAuth2/OpenID provider in Authentik plus the OpenID Connect Generic plugin in WordPress, with every value listed below.</p>



<p class="wp-block-paragraph"><strong>Tested with:</strong> WordPress 7.1, Authentik 2026.8, OpenID Connect Generic 3.11</p>
</div>



<p class="wp-block-paragraph">I use Authentik as the single login for my self-hosted services. This blog should work the same way: one click on the login button, passkey, done. The normal WordPress login stays as a fallback, in case Authentik is ever down.</p>



<p class="wp-block-paragraph">In the examples, Authentik runs at <code>https://auth.example.com</code>, the blog at <code>https://blog.example.com</code>, and the Authentik application slug is <code>blog</code>. Replace these with your own values.</p>



<h2 class="wp-block-heading">How the login works</h2>



<ol class="wp-block-list">
<li>You click the login button on the WordPress login page.</li>



<li>WordPress sends you to Authentik, where you log in, for example with a passkey.</li>



<li>Authentik sends you back to WordPress with a one-time code.</li>



<li>WordPress exchanges the code for tokens in the background, reads your username and e-mail, and logs you in as the matching WordPress user.</li>
</ol>



<h2 class="wp-block-heading">1. Authentik: create the provider</h2>



<p class="wp-block-paragraph">In the Authentik admin interface go to <strong>Applications → Providers → Create</strong> and choose <strong>OAuth2/OpenID Provider</strong>. These are the settings I use:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Setting</th><th>Value</th></tr></thead><tbody><tr><td>Name</td><td>Provider for Blog</td></tr><tr><td>Authorization flow</td><td><code>default-provider-authorization-implicit-consent</code> (no extra &#8220;Allow access?&#8221; screen)</td></tr><tr><td>Invalidation flow</td><td><code>default-provider-invalidation-flow</code></td></tr><tr><td>Client type</td><td>Confidential</td></tr><tr><td>Client ID / Client Secret</td><td>generated by Authentik, copy both for WordPress</td></tr><tr><td>Redirect URI (strict)</td><td><code>https://blog.example.com/wp-admin/admin-ajax.php?action=openid-connect-authorize</code></td></tr><tr><td>Signing key</td><td>authentik Self-signed Certificate</td></tr><tr><td>Scopes</td><td>the default mappings for <code>openid</code>, <code>email</code> and <code>profile</code></td></tr><tr><td>Subject mode</td><td>Based on the User&#8217;s hashed ID</td></tr><tr><td>Include claims in id_token</td><td>on</td></tr><tr><td>Issuer mode</td><td>Each provider has a different issuer</td></tr><tr><td>Grant types</td><td>Authorization code and refresh token</td></tr><tr><td>Access token validity</td><td>5 minutes</td></tr><tr><td>Refresh token validity</td><td>30 days</td></tr></tbody></table></figure>



<div class="wp-block-group dn-warning has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Watch out:</strong> if you create the provider through the Authentik API instead of the UI, set <code>grant_types</code> yourself. Without it the list stays empty and every login ends with &#8220;The request is otherwise malformed&#8221;. The Authentik server log then shows &#8220;Invalid grant_type for provider&#8221;.</p>
</div>



<p class="wp-block-paragraph">For reference, the same provider created through the API:</p>



<pre class="wp-block-code"><code>curl -X POST https://auth.example.com/api/v3/providers/oauth2/ \
  -H "Authorization: Bearer $AUTHENTIK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Provider for Blog",
    "client_type": "confidential",
    "authorization_flow": "&lt;uuid of default-provider-authorization-implicit-consent&gt;",
    "invalidation_flow": "&lt;uuid of default-provider-invalidation-flow&gt;",
    "property_mappings": ["&lt;openid uuid&gt;", "&lt;email uuid&gt;", "&lt;profile uuid&gt;"],
    "signing_key": "&lt;uuid of the signing certificate&gt;",
    "sub_mode": "hashed_user_id",
    "include_claims_in_id_token": true,
    "grant_types": ["authorization_code", "refresh_token"],
    "redirect_uris": [{
      "matching_mode": "strict",
      "url": "https://blog.example.com/wp-admin/admin-ajax.php?action=openid-connect-authorize"
    }]
  }'</code></pre>



<h2 class="wp-block-heading">2. Authentik: create the application</h2>



<p class="wp-block-paragraph">Go to <strong>Applications → Applications → Create</strong>:</p>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Setting</th><th>Value</th></tr></thead><tbody><tr><td>Name</td><td>Blog</td></tr><tr><td>Slug</td><td><code>blog</code> (it becomes part of the issuer and JWKS URLs)</td></tr><tr><td>Provider</td><td>Provider for Blog</td></tr><tr><td>Launch URL</td><td><code>https://blog.example.com/wp-admin/</code></td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Then open the application, go to <strong>Policy / Group / User Bindings</strong> and bind the users or the group who may log in to WordPress. Without a binding, every Authentik user can use the application.</p>



<p class="wp-block-paragraph">All endpoints are listed in the discovery document of the application. Open it once to check that it works:</p>



<pre class="wp-block-code"><code>curl -s https://auth.example.com/application/o/blog/.well-known/openid-configuration</code></pre>



<h2 class="wp-block-heading">3. WordPress: install the plugin</h2>



<p class="wp-block-paragraph">Install and activate <strong>OpenID Connect Generic</strong> (by daggerhart) from the plugin directory. The settings are under <strong>Settings → OpenID Connect Client</strong>. As soon as the plugin is active, the login page shows a button. It only works once the settings below are saved.</p>



<h2 class="wp-block-heading">4. WordPress: all plugin settings</h2>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody><tr><td>Login Type</td><td>OpenID Connect button on login form</td></tr><tr><td>Client ID</td><td>from the Authentik provider</td></tr><tr><td>Client Secret Key</td><td>from the Authentik provider</td></tr><tr><td>OpenID Scope</td><td><code>openid email profile</code></td></tr><tr><td>Login Endpoint URL</td><td><code>https://auth.example.com/application/o/authorize/</code></td></tr><tr><td>Userinfo Endpoint URL</td><td><code>https://auth.example.com/application/o/userinfo/</code></td></tr><tr><td>Token Validation Endpoint URL</td><td><code>https://auth.example.com/application/o/token/</code></td></tr><tr><td>End Session Endpoint URL</td><td><code>https://auth.example.com/application/o/blog/end-session/</code></td></tr><tr><td>JWKS URI</td><td><code>https://auth.example.com/application/o/blog/jwks/</code></td></tr><tr><td>Issuer (if your version asks)</td><td><code>https://auth.example.com/application/o/blog/</code></td></tr><tr><td>ACR values</td><td>leave empty</td></tr><tr><td>Identity Key</td><td><code>preferred_username</code></td></tr><tr><td>Disable SSL Verify</td><td>off</td></tr><tr><td>HTTP Request Timeout</td><td>5</td></tr><tr><td>Nickname Key</td><td><code>preferred_username</code></td></tr><tr><td>Email Formatting</td><td><code>{email}</code></td></tr><tr><td>Display Name Formatting</td><td><code>{name}</code></td></tr><tr><td>Identify with User Name</td><td>off</td></tr><tr><td>Enable Refresh Token</td><td>on</td></tr><tr><td>Link Existing Users</td><td>on</td></tr><tr><td>Create user if does not exist</td><td>off</td></tr><tr><td>Redirect Back to Origin Page</td><td>on</td></tr><tr><td>Redirect to the login screen when session is expired</td><td>off</td></tr><tr><td>Enable Logging</td><td>on while testing, off afterwards</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Only the JWKS URI, the End Session URL and the issuer contain the application slug. The authorize, token and userinfo endpoints are the same for every application in Authentik.</p>



<div class="wp-block-group dn-tip has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<p class="wp-block-paragraph"><strong>Tip:</strong> the bottom of the plugin settings page shows the exact Redirect URI WordPress will use. Copy it from there into Authentik, so the two match character for character.</p>
</div>



<h3 class="wp-block-heading">How existing users are matched</h3>



<p class="wp-block-paragraph">With <em>Link Existing Users</em> on, the plugin looks for a WordPress user whose username matches the value of the identity key, here the Authentik username (<code>preferred_username</code>). The first login links the two accounts, and after that the link is fixed. Use the same username in WordPress and Authentik, or log in once with the matching account. With <em>Create user if does not exist</em> off, nobody gets a new WordPress account just because they have an Authentik login.</p>



<h3 class="wp-block-heading">Optional: keep the secret out of the database</h3>



<p class="wp-block-paragraph">Instead of typing the values into the settings page, you can define them in <code>wp-config.php</code>. The plugin then uses these values and shows the fields as read-only:</p>



<pre class="wp-block-code"><code>define( 'OIDC_CLIENT_ID', 'your-client-id' );
define( 'OIDC_CLIENT_SECRET', 'your-client-secret' );
define( 'OIDC_CLIENT_SCOPE', 'openid email profile' );
define( 'OIDC_ENDPOINT_LOGIN_URL', 'https://auth.example.com/application/o/authorize/' );
define( 'OIDC_ENDPOINT_USERINFO_URL', 'https://auth.example.com/application/o/userinfo/' );
define( 'OIDC_ENDPOINT_TOKEN_URL', 'https://auth.example.com/application/o/token/' );
define( 'OIDC_ENDPOINT_LOGOUT_URL', 'https://auth.example.com/application/o/blog/end-session/' );</code></pre>



<h2 class="wp-block-heading">5. Test it</h2>



<p class="wp-block-paragraph">Before clicking the button, test the authorize request from the command line. A working provider redirects to the Authentik login flow:</p>



<pre class="wp-block-code"><code>curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" \
  "https://auth.example.com/application/o/authorize/?response_type=code&amp;scope=openid%20email%20profile&amp;client_id=YOUR_CLIENT_ID&amp;state=test&amp;redirect_uri=https%3A%2F%2Fblog.example.com%2Fwp-admin%2Fadmin-ajax.php%3Faction%3Dopenid-connect-authorize"

# good:  302 -> https://auth.example.com/if/flow/default-authentication-flow/?...
# wrong: 302 -> https://blog.example.com/...?error=invalid_request&amp;...</code></pre>



<p class="wp-block-paragraph">If that looks good, log out of WordPress, click the login button and log in with Authentik. You should land in the dashboard as your existing user.</p>



<h2 class="wp-block-heading">Troubleshooting</h2>



<figure class="wp-block-table is-style-stripes"><table><thead><tr><th>What you see</th><th>Likely cause</th></tr></thead><tbody><tr><td>&#8220;The request is otherwise malformed&#8221;</td><td>No grant types on the provider (created through the API)</td></tr><tr><td>&#8220;Redirect URI Error&#8221;</td><td>The redirect URI in Authentik differs from the one WordPress sends, even by a slash</td></tr><tr><td>Back on WordPress, but not logged in</td><td>No WordPress user matches <code>preferred_username</code>, and user creation is off</td></tr><tr><td>&#8220;Permission denied&#8221; in Authentik</td><td>Your user is not bound to the application</td></tr><tr><td>A blank page with &#8220;0&#8221;</td><td>Authentik returned an error to the redirect URI. Check the Authentik server log.</td></tr></tbody></table></figure>



<h2 class="wp-block-heading">What I learned</h2>



<ul class="wp-block-list">
<li>The Authentik UI fills in defaults that the API does not.</li>



<li>The server log tells you the real reason when the browser only shows a generic error.</li>



<li>Keep the local WordPress login as a fallback. If the identity provider is down, you still want to reach your own site.</li>
</ul>

]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
