Zammad M365 Graph Email OAuth always returns 403 Forbidden after consent

Infos:

  • Used Zammad version: 6.5.0-1744362684.c8a525b6.bookworm
  • Used Zammad installation type: package
  • Operating system: Debian GNU/Linux 12 (bookworm)
  • Browser + version: Firefox / Brave / Google Chrome (latest)

Expected behavior:

  • After migrating the help@domain-name mailbox from on‑prem Exchange to Exchange Online (Microsoft 365), Zammad should be able to:
    • Connect via the Microsoft 365 Graph / Microsoft 365 IMAP channel using OAuth2.
    • Successfully complete the OAuth consent flow without errors.
    • Fetch incoming mail from the help@domain-name mailbox and create tickets.
    • Send auto‑replies to users and notifications to administrators as before.

Actual behavior:

  • Old Email + Basic Authentication channel (pointing to on‑prem Exchange) no longer works after mailbox migration to Exchange Online.
  • Attempts to reconfigure the channel to use:
    • IMAP host outlook.office365.com with Basic Auth → “Login failed”.
    • Microsoft 365 (Graph / Online) with OAuth2 → after successful Microsoft login and consent, Zammad shows Forbidden You don’t have permission to access this resource.
  • The OAuth callback URL https://zammad.example.com/api/v1/external_credentials/microsoft_graph/callback:
    • Is reachable from the internet.
    • Returns a 422 “The required parameter ‘code’ is missing” when accessed directly in a browser (so the endpoint itself is reachable).
  • A dedicated Azure App Registration for the mail channel was created:
    • Redirect URI matches exactly: https://zammad.example.com/api/v1/external_credentials/microsoft_graph/callback.
    • Delegated permissions (Mail.ReadWrite, Mail.Send, offline_access, etc.) are added and admin consent is granted. (Accounts — Zammad Admin Documentation documentation)
    • Client ID, client secret (value), and tenant ID are correctly entered in the Microsoft 365 channel configuration in Zammad.
  • Despite all of the above, every attempt to “Add account” or “Request Admin Consent” and logging in as Global Admin for Microsoft 365 in Zammad ends with 403 Forbidden after pressing “Allow” on the Microsoft consent screen.

Steps to reproduce the behavior:

  1. Migrate an existing help@domain-name mailbox from on-premises Exchange to Exchange Online (Microsoft 365).
  2. In Zammad, disable the old Email channel that used:
    • IMAP/SMTP to the on‑prem Exchange host.
    • Basic Authentication.
  3. In Azure AD:
    • Create a new App Registration for the Zammad mail channel.
    • Set redirect URI (Web) to:
      https://zammad.example.com/api/v1/external_credentials/microsoft_graph/callback
    • Add delegated permissions for Microsoft Graph, including at least: (Accounts — Zammad Admin Documentation documentation)
      • offline_access
      • email
      • Mail.ReadWrite
      • Mail.Send
    • Click “Grant admin consent for tenant”.
    • Create a client secret and copy the secret value.
  4. In Zammad admin:
    • Go to Settings → Channels → Microsoft 365 Graph Email.
    • Configure the Microsoft 365 Graph Email channel with:
      • Client ID = Application (client) ID of the mail app.
      • Client secret = secret value.
      • Tenant ID = Directory (tenant) ID (or leave empty, tested both).
    • Save.
  5. Click Add account in the Microsoft 365 Graph Email channel:
    • Log in with either the help@domain-name account or a Global Administrator.
    • Click “Allow” on the Microsoft consent screen.
  6. Immediate redirect to Forbidden You don’t have permission to access this resource, and the account is not added.

Additional notes:

  • FQDN matches the URI Redirect

To provide some extra information:

"This is the conf of nginx zammad
"## this is the nginx config for zammad

upstream zammad-railsserver {
server 127.0.0.1:3000;
}

upstream zammad-websocket {
server 127.0.0.1:6042;
}

server {
# replace ‘localhost’ with your fqdn if you want to use zammad from remote
server_name URI-of-zammad;
# security - prevent information disclosure about server version
server_tokens off;

root /opt/zammad/public;

access_log /var/log/nginx/zammad.access.log;
error_log /var/log/nginx/zammad.error.log;

client_max_body_size 50M;

location ~ ^/(assets/|robots.txt|humans.txt|favicon.ico|apple-touch-icon.png) {
expires max;
}

location /ws {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection “Upgrade”;
proxy_set_header CLIENT_IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
proxy_pass http:// zammad-websocket # (intentionally added extra space, because zammad community allows only one link per post);
}

location / {
proxy_set_header Host $http_host;
proxy_set_header CLIENT_IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Change this line in an SSO setup
proxy_set_header X-Forwarded-User “”;
proxy_read_timeout 300;
proxy_pass http:// zammad-railserver # (intentionally added an extra space because zammad community allows only one link per post);
gzip on;
gzip_types text/plain text/xml text/css image/svg+xml application/javascript application/x-javascript application/json application/xml;
gzip_proxied any;
}
listen [::]:443 ssl ipv6only=on; # managed by Certbot
listen 443; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/URI-of-zammad/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/URI-of-zammad/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot

}

server {
if ($host = URI-of-zammad) {
return 301 https://$host$request_uri;
} # managed by Certbot

listen 80;
listen [::]:80;
server_name URI-of-zammad;
return 404; # managed by Certbot

}"


This is the zammad logs:
“==> /var/log/zammad/production.log <==
I, [2026-01-27T14:43:34.253030#2575545-196240] INFO – : Running job thread for ‘Process ticket escalations.’ (Ticket.process_escalation) status is: sleep
I, [2026-01-27T14:43:34.253089#2575545-196240] INFO – : Running job thread for ‘Check channels.’ (Channel.fetch) status is: sleep
I, [2026-01-27T14:43:34.253178#2575545-196240] INFO – : Running job thread for ‘Check ‘Channel’ streams.’ (Channel.stream) status is: sleep
I, [2026-01-27T14:43:34.254256#2575545-196240] INFO – : Running job thread for ‘Execute planned jobs.’ (Job.run) status is: sleep
I, [2026-01-27T14:43:34.277617#3508588-43435780] INFO – : Completed 200 OK in 25026ms (Views: 0.2ms | ActiveRecord: 1.3ms (10 queries, 5 cached) | GC: 7.5ms)
I, [2026-01-27T14:43:34.295399#3508588-43393620] INFO – : Started POST “/api/v1/message_receive” for 192.168.x.x at 2026-01-27 14:43:34 +0100
I, [2026-01-27T14:43:34.299849#3508588-43393620] INFO – : Processing by LongPollingController#message_receive as JSON
I, [2026-01-27T14:43:34.299902#3508588-43393620] INFO – : Parameters: {“client_id”=>“XXX”}
I, [2026-01-27T14:43:38.338094#2575545-5062600] INFO – : execute Channel.fetch (try_count 0)…
I, [2026-01-27T14:43:38.339146#2575545-5062600] INFO – : ended Channel.fetch took: 0.004884017 seconds.”


Nginx logs:

“2026/01/27 03:28:47 [error] 2937786#2937786: *416005 open() “/opt/zammad/public/robots.txt” failed (2: No such file or directory), client: 192.168.x.x, server: URI-of-zammad, request: “GET /robots.txt HTTP/1.1”, host: “URI-of-zammad”
2026/01/27 08:33:27 [error] 2937786#2937786: *417145 upstream prematurely closed connection while reading response header from upstream, client: 192.168.x.x, server: URI-of-zammad, request: “GET /ws HTTP/1.1”, upstream: “http:// 127.0.0.1:6042/ws # (intentionally added an extra space because zamamd community allows only one link per post)”, host: “URI-of-zammad”
2026/01/27 08:50:15 [error] 2937785#2937785: *417487 upstream prematurely closed connection while reading response header from upstream, client: 192.168.x.x, server: URI-of-zammad, request: “GET /ws HTTP/1.1”, upstream: “http:// 127.0.0.1:6042/ws # (intentionally added an extra space because zamamd community allows only one link per post)”, host: “URI-of-zammad”
2026/01/27 08:51:50 [error] 2937785#2937785: *417508 upstream prematurely closed connection while reading response header from upstream, client: 192.168.x.x, server: URI-of-zammad, request: “GET /ws HTTP/1.1”, upstream: “http:// 127.0.0.1:6042/ws # (intentionally added an extra space because zamamd community allows only one link per post)”, host: “URI-of-zammad”
2026/01/27 14:36:07 [error] 2937785#2937785: *421648 upstream prematurely closed connection while reading response header from upstream, client: 192.168.x.x, server: URI-of-zammad, request: “GET /ws HTTP/1.1”, upstream: “http:// 127.0.0.1:6042/ws # (intentionally added an extra space because zamamd community allows only one link per post)”, host: “URI-of-zammad”
2026/01/27 14:36:10 [error] 2937786#2937786: *421654 upstream prematurely closed connection while reading response header from upstream, client: 192.168.x.x, server: URI-of-zammad, request: “GET /ws HTTP/1.1”, upstream: “http:// 127.0.0.1:6042/ws # (intentionally added an extra space because zamamd community allows only one link per post)”, host: “URI-of-zammad”
2026/01/27 14:40:48 [error] 2937785#2937785: *421766 upstream prematurely closed connection while reading response header from upstream, client: 192.168.x.x, server: URI-of-zammad, request: “GET /ws HTTP/1.1”, upstream: “http:// 127.0.0.1:6042/ws # (intentionally added an extra space because zamamd community allows only one link per post)”, host: “URI-of-zammad”
2026/01/27 14:41:08 [error] 2937785#2937785: *421802 upstream prematurely closed connection while reading response header from upstream, client: 192.168.x.x, server: URI-of-zammad, request: “GET /ws HTTP/1.1”, upstream: “http:// 127.0.0.1:6042/ws # (intentionally added an extra space because zamamd community allows only one link per post)”, host: “URI-of-zammad””"

Even more extra Information:

Azure > Conditional Access > Sign-in logs & Audit logs show Successes
Orange = Global Admin (Request Consent option)
Green = help@domain-name (Add Account option)

image

Anyone, any solutions, suggestions?

Bump

Please do not bump threads, thank you.
If this is such a high pressure issue for you that you cannot wait, you might want to consider a Zammad support contract for commercial grade support.