Authelia (external OIDC), setup and error

Your question

Stalwart Version: 0.16.11 (docker, community) behind caddy, no proxy protocol.

I am trying to convince stalwart to authenticate users against authelia.
Since authelia works fine for other apps, I’d assume it’s a stalwart specific issue I have.

I ended up with this configuration for authelia:

  - client_id: 'stalwart-webui'
    client_name: 'Stalwart'
    client_secret: '$pbkdf2-sha512$310000$z3H***MLA'
    public: false
    authorization_policy: 'one_factor'
    require_pkce: true
    pkce_challenge_method: 'S256'
    redirect_uris:
      - 'https://mail.example.com/account/oauth/callback'
    scopes:
      - 'openid'
      - 'profile'
      - 'email'
      - 'offline_access'
    response_types:
      - 'code'
    grant_types:
      - 'authorization_code'
    access_token_signed_response_alg: 'none'
    userinfo_signed_response_alg: 'none'
    token_endpoint_auth_method: 'client_secret_basic'

Stalwart OIDC Directory entry looks like this:

Issuer URL: https://auth.example.com
Required Audience:
Required Scopes: email, openid
Username Claim: preferred_username
Username Domain:
Name Claim: name
Groups Claim:
Tenant: (Community Version)

This brings me to a partially working thing:

1.) connect to https://mail.example.com
2.) login with user [email protected]
3.) being redirected to authelia
4.) loggin in with [email protected] + pwd
5.) confirm consent
6.) ERROR: “Failed to fetch, Back to login”

URL leading to the error: https://mail.example.com/account/oauth/callback?code=authelia_ac_uCGM0PXvz8s_***-M-fIg&iss=https%3A%2F%2Fauth.example.com&scope=openid+email+profile+offline_access&state=0a4906dc8a8dddabd7701d012d970f75

The user already exists in stalwarts’ internal database since a (working) ldap authentication is my current standard login method.
Since it’s the same ldap authelia uses, I wouldn’t expect any pwd or schema-issues.

I am pretty sure I’ve checked all available documentation, if I missed it anyway I’d be thankful for pointing me into the right direction.

Any ideas what I am doing wrong here?

I attach tracelogs, just one line has something like an error but since it appears before all the oidc-magic I am not sure wether it’s relevant.

Cheers

Michael aka /Bytewurm

2026-07-05T12:16:55Z DEBUG HTTP request URL (http.request-url) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, url = "/"
2026-07-05T12:16:55Z TRACE Expression evaluation result (eval.result) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, id = "Http with id singleton", key = "allowedEndpoints", result = "Integer(200)"
2026-07-05T12:16:55Z TRACE HTTP response body (http.response-body) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, contents = (null), code = 302, size = 0
2026-07-05T12:16:55Z DEBUG HTTP request URL (http.request-url) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, url = "/account"
2026-07-05T12:16:55Z TRACE Expression evaluation result (eval.result) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, id = "Http with id singleton", key = "allowedEndpoints", result = "Integer(200)"
2026-07-05T12:16:55Z TRACE HTTP response body (http.response-body) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, contents = (null), code = 302, size = 0
2026-07-05T12:16:55Z DEBUG HTTP request URL (http.request-url) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, url = "/account/"
2026-07-05T12:16:55Z TRACE Expression evaluation result (eval.result) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, id = "Http with id singleton", key = "allowedEndpoints", result = "Integer(200)"
2026-07-05T12:16:55Z TRACE HTTP response body (http.response-body) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, contents = "[binary data]", code = 200, size = 465
2026-07-05T12:16:55Z DEBUG HTTP request URL (http.request-url) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, url = "/logo"
2026-07-05T12:16:55Z TRACE Expression evaluation result (eval.result) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, id = "Http with id singleton", key = "allowedEndpoints", result = "Integer(200)"
2026-07-05T12:16:55Z TRACE HTTP response body (http.response-body) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, contents = (null), code = 302, size = 0
2026-07-05T12:16:55Z DEBUG HTTP request URL (http.request-url) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, url = "/logo/"
2026-07-05T12:16:55Z TRACE Expression evaluation result (eval.result) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, id = "Http with id singleton", key = "allowedEndpoints", result = "Integer(200)"
2026-07-05T12:16:55Z DEBUG Resource not found (resource.not-found) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824

2026-07-05T12:16:55Z TRACE HTTP response body (http.response-body) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, contents = "{"type":"about:blank","status":404,"title":"Not Found","detail":"The requested resource does not exist on this server."}", code = 404, size = 120

2026-07-05T12:16:59Z DEBUG HTTP request URL (http.request-url) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, url = "/api/discover/stalwart_test%40example.com"
2026-07-05T12:16:59Z TRACE Expression evaluation result (eval.result) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, id = "Http with id singleton", key = "allowedEndpoints", result = "Integer(200)"
2026-07-05T12:16:59Z TRACE Write batch operation (store.data-write) elapsed = 0ms, total = 2
2026-07-05T12:16:59Z TRACE HTTP response body (http.response-body) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, contents = "{"issuer":"https://auth.example.com","jwks_uri":"https://auth.example.com/jwks.json","userinfo_endpoint":"https://auth.example.com/api/oidc/userinfo","token_endpoint":"https://auth.example.com/api/oidc/token","authorization_endpoint":"https://auth.example.com/api/oidc/authorization","scopes_supported":["offline_access","openid","profile","email","address","phone","groups"],"claims_supported":["amr","aud","azp","client_id","exp","iat","iss","jti","rat","auth_time","nonce","groups","sub","name","given_name","family_name","middle_name","nickname","preferred_username","profile","picture","website","email","email_verified","alt_emails","gender","birthdate","zoneinfo","locale","phone_number","phone_number_verified","address","updated_at"],"code_challenge_methods_supported":["S256"]}", code = 200, size = 772
2026-07-05T12:17:06Z DEBUG HTTP connection started (http.connection-start) listenerId = "https", localPort = 443, remoteIp = 127.0.0.1, remotePort = 37754
2026-07-05T12:17:06Z DEBUG HTTP request URL (http.request-url) listenerId = "https", localPort = 443, remoteIp = 127.0.0.1, remotePort = 37754, url = "/healthz/live"
2026-07-05T12:17:06Z TRACE Expression evaluation result (eval.result) listenerId = "https", localPort = 443, remoteIp = 127.0.0.1, remotePort = 37754, id = "Http with id singleton", key = "allowedEndpoints", result = "Integer(200)"
2026-07-05T12:17:06Z TRACE HTTP response body (http.response-body) listenerId = "https", localPort = 443, remoteIp = 127.0.0.1, remotePort = 37754, contents = "{"type":"about:blank","title":"OK","status":200,"detail":"OK"}", code = 200, size = 62
2026-07-05T12:17:06Z DEBUG HTTP connection ended (http.connection-end) listenerId = "https", localPort = 443, remoteIp = 127.0.0.1, remotePort = 37754, elapsed = 4ms
2026-07-05T12:17:09Z DEBUG HTTP request URL (http.request-url) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, url = "/account/oauth/callback?code=authelia_ac_uCGM0PXvz8s__DQ8E3mYbLP7PkQ24dxp9bL-JZhg4qw.ue59bW7epDWV_SQ4FuwlaQZ66PXjJ6OpYaZQp-M-fIg&iss=https%3A%2F%2Fauth.example.com&scope=openid+email+profile+offline_access&state=0a4906dc8a8dddabd7701d012d970f75"
2026-07-05T12:17:09Z TRACE Expression evaluation result (eval.result) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, id = "Http with id singleton", key = "allowedEndpoints", result = "Integer(200)"
2026-07-05T12:17:09Z TRACE HTTP response body (http.response-body) listenerId = "http", localPort = 8080, remoteIp = 172.22.0.7, remotePort = 35824, contents = "[binary data]", code = 200, size = 465

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

This is almost certainly a client-authentication mismatch, not a Stalwart-directory issue. The WebUI performs the OIDC login as a public client with PKCE: when it calls the token endpoint it only sends grant_type, code, code_verifier, client_id, and redirect_uri — it never sends a client secret or an Authorization header. (Source: services/auth/oauth.ts, exchangeCode().)

Your Authelia client stalwart-webui is configured as:

public: false
token_endpoint_auth_method: 'client_secret_basic'

That tells Authelia to require Basic-auth client credentials on the token request. Since the WebUI never sends them, Authelia will reject the token exchange, and the browser reports that failure generically as “Failed to fetch” (this is also often a CORS symptom when the token endpoint rejects the cross-origin request).

Fix: register the WebUI client in Authelia as a public PKCE client, i.e.:

public: true
token_endpoint_auth_method: 'none'

and remove/blank client_secret, keeping require_pkce: true / pkce_challenge_method: 'S256'. Also make sure Authelia’s token endpoint allows CORS from https://mail.example.com, since the exchange happens directly from the browser.

Your Stalwart Directory (OIDC) settings for validating the resulting access token look fine and are unrelated to this failure; that config only governs how Stalwart verifies tokens presented over IMAP/SMTP/JMAP via OAUTHBEARER, not the WebUI’s own login flow.

Reference: OpenID Connect provider/client docs — public clients “leave the secret field empty and rely on PKCE for flow protection.”

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

unfortunately the setups for

public: true
token_enpoint_auth_method: 'none'

do not change the effect.

I have even tried to make CORS more permissive, but since authelia doku says it will handle all CORS-Request via https that contain an Origin-header I am unsure where to look next.

It was CORS :frowning:

Checking this twice was not enough, leaving things alone for a day and some AI abused as flexible manual fixed authelia setup:

identity_providers:

  oidc:

    cors:

      endpoints:

        - token

        - userinfo

      allowed_origins_from_client_redirect_uris: true