ZWIEGNET Linux Consultants

Configure Postfix with OAuth2 for Microsoft 365 on AlmaLinux 9

Secure SMTP Relay – Compatible with RHEL, AlmaLinux, Rocky Linux

This guide was originally set up and tested on AlmaLinux 9 but the configuration is fully compatible with Red Hat Enterprise Linux (RHEL 8/9/10), AlmaLinux, and Rocky Linux where the required packages (including sasl-xoauth2 from EPEL) are available.

Critical note: Microsoft is phasing out Basic Authentication for SMTP AUTH client submission in Exchange Online starting March 1, 2026 (with increasing rejections, full disablement by default end of 2026, and final removal in 2027). Even Windows IIS SMTP relay using basic auth will stop working reliably for authenticated submission to smtp.office365.com. Only OAuth2-capable solutions — like this Linux Postfix + sasl-xoauth2 setup — will continue to function after the deprecation enforcement.

Need Help Migrating to OAuth2? Get Magic Hands Support

1. Microsoft 365 / Office 365 Configuration

Prepare your Microsoft 365 tenant before configuring Postfix. Special thanks to my friend Nathan for his help and expertise with the Entra ID / O365 side of this setup!

Enable SMTP Authentication Organization-Wide

  • Log into exchange.microsoft.com with admin credentials.
  • Go to Settings → Mail flow.
  • Uncheck "Turn off SMTP Auth Protocol for your organization" and click Save.

Enable Authenticated SMTP for the Relay User

  • Log into admin.microsoft.com with admin credentials.
  • Left menu: Users → Active Users.
  • Select the relay account (must have Exchange Online license).
  • In the flyout: Mail → Manage email apps.
  • Check "Authenticated SMTP" and save changes.

Register App in Microsoft Entra ID

  • Log into entra.microsoft.com.
  • App registrations → New registration.
  • Enter a user-facing name (e.g., "Postfix OAuth Relay").
  • Navigate to Authentication → Add Redirect URI:
    • Platform: Web
    • URI: https://login.microsoftonline.com/common/oauth2/nativeclient
  • API Permissions → Add a Permission → Microsoft Graph → Delegated permissions → Mail.Send.
  • Grant admin consent for your tenant.

Note your Application (client) ID and Directory (tenant) ID from the app Overview. Enable "Allow public client flows" in Authentication settings if using device flow (recommended).

2. Postfix Configuration on AlmaLinux 9 (RHEL / Rocky Compatible)

Important: As noted above, after March 1, 2026, basic auth for SMTP AUTH to Exchange Online will face increasing rejections. Windows solutions like IIS SMTP virtual servers relying on basic credentials will fail. This Linux-based OAuth2 configuration ensures continued reliable outbound email relay.

Things to Note

Tested on AlmaLinux 9; compatible with RHEL / AlmaLinux / Rocky Linux (EL8/EL9/EL10 variants).

systemctl disable firewalld
sed -i 's/enforcing/disabled/g' /etc/selinux/config

Install Required Packages

dnf -y install epel-release
dnf install -y \
    postfix \
    cyrus-sasl \
    cyrus-sasl-plain \
    cyrus-sasl-md5 \
    cyrus-sasl-devel \
    sasl-xoauth2 \
    nagios-plugins-mailq \
    postfix-pflogsumm \
    jsoncpp \
    python3-pip \
    openssl-devel \
    cmake \
    python3-msal \
    swaks

Enable and Start Postfix

systemctl enable postfix
systemctl start postfix

Basic Postfix Configuration (/etc/postfix/main.cf)

# Disable IPv6 if not needed
inet_protocols = ipv4

mydomain = yourdomain.com
myhostname = mail.yourdomain.com

mynetworks = 192.168.1.0/23, 127.0.0.0/8
inet_interfaces = all

smtp_tls_security_level = encrypt

Create Directories

mkdir -p /data/bin
cd /etc/postfix
mkdir ssl sasl tokens

Create sasl-xoauth2.conf (/etc/sasl-xoauth2.conf)

{
  "client_id": "YOUR_CLIENT_ID",
  "client_secret": "",
  "token_endpoint": "https://login.microsoftonline.com/YOUR_TENANT_ID/oauth2/v2.0/token",
  "log_to_syslog_on_failure": "yes",
  "log_full_trace_on_failure": "yes"
}

Place Certificate(s) in /etc/postfix/ssl

# Example: vi /etc/postfix/ssl/cert.yourdomain.com.pem
# Paste your full PEM certificate chain + private key

Sender Canonical Maps (rewrite domains)

# /etc/postfix/sender_canonical_maps
/^(.*)@(.*)\.yourinternal\.net$/    ${1}@yourdomain.com
postmap /etc/postfix/sender_canonical_maps

Header Checks

# /etc/postfix/header_checks
/^subject:/ WARN

Sender Relay Maps

# /etc/postfix/sender_relay_maps
@yourdomain.com   [smtp.office365.com]:587
postmap /etc/postfix/sender_relay_maps

Transport Maps (force external mail via O365)

# /etc/postfix/transport
*                               smtp:[smtp.office365.com]:587
yourdomain.com          smtp:[smtp.office365.com]:587
postmap /etc/postfix/transport

SASL Client Config with Large Buffer (/etc/postfix/sasl/smtp.conf)

# Cyrus SASL client configuration for Postfix outbound
mech_list: PLAIN LOGIN XOAUTH2
maxbufsize: 524288

Final main.cf Additions (bottom of file)

smtpd_tls_CAfile = /etc/pki/tls/certs/ca-bundle.crt
disable_vrfy_command = yes

# Restrict to modern TLS versions only
smtpd_tls_mandatory_protocols = !SSLv2:!SSLv3:!TLSv1:!TLSv1_1
smtpd_tls_protocols          = !SSLv2:!SSLv3:!TLSv1
smtp_tls_mandatory_protocols = TLSv1.2, TLSv1.3
smtp_tls_protocols           = TLSv1.2, TLSv1.3

smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
smtp_sasl_security_options = noanonymous
smtp_sasl_tls_security_options = noanonymous

smtp_use_tls = yes
smtp_tls_loglevel = 2

smtpd_tls_security_level = may
smtpd_tls_auth_only = yes
smtpd_tls_loglevel = 1
smtpd_tls_received_header = yes
smtpd_tls_session_cache_database = btree:/var/lib/postfix/smtpd_scache

# OAuth2
smtp_sasl_mechanism_filter = xoauth2

# Sender rewriting
sender_canonical_classes = envelope_sender, header_sender
sender_canonical_maps = regexp:/etc/postfix/sender_canonical_maps
smtp_header_checks = regexp:/etc/postfix/header_checks

# Size & queue
message_size_limit = 10240000
maximal_queue_lifetime = 7d

# Restrict relay
smtpd_client_restrictions = permit_mynetworks, reject

# Debugging
debug_peer_list = smtp.office365.com
cyrus_sasl_config_path = /etc/postfix/sasl

# Force outbound via Office 365
relay_domains =
transport_maps = hash:/etc/postfix/transport

Restart Postfix

systemctl restart postfix

Get Initial OAuth Token

sasl-xoauth2-tool get-token outlook \
  /etc/postfix/tokens/svc-smtp@yourdomain.com \
  --client-id YOUR_CLIENT_ID \
  --tenant YOUR_TENANT_ID \
  --use-device-flow

Follow the device flow instructions in the browser. It should say “token acquired” when successful.

Token Refresh Script (/data/bin/refresh-o365-token.sh)

#!/bin/bash
set -euo pipefail

### CONFIG
USER="svc-smtp@yourdomain.com"
TOKEN_JSON="/etc/postfix/tokens/${USER}"
SASL_PASSWD="/etc/postfix/sasl_passwd"
CONF="/etc/sasl-xoauth2.conf"
LOGTAG="o365-xoauth2"

CURL="/usr/bin/curl"
POSTMAP="/usr/sbin/postmap"
POSTFIX="/usr/sbin/postfix"

[[ -r "$CONF" ]] || { echo "ERROR: cannot read $CONF"; exit 2; }
[[ -r "$TOKEN_JSON" ]] || { echo "ERROR: cannot read $TOKEN_JSON"; exit 3; }
[[ -x "$CURL" ]] || { echo "ERROR: curl not found"; exit 4; }

# Load client_id + token_endpoint
read -r CLIENT_ID TOKEN_ENDPOINT < <(python3 - <<'PY'
import json, sys
cfg = json.load(open("/etc/sasl-xoauth2.conf"))
cid = cfg.get("client_id", "").strip()
tep = cfg.get("token_endpoint", "").strip()
if not cid: sys.exit("ERROR: missing client_id")
if not tep: sys.exit("ERROR: missing token_endpoint")
print(cid, tep)
PY
)

# Load refresh_token + scope
read -r REFRESH_TOKEN SCOPE < <(python3 - <<'PY'
import json, sys
d = json.load(open("/etc/postfix/tokens/svc-smtp@yourdomain.com"))
rt = d.get("refresh_token", "").strip()
if not rt: sys.exit("ERROR: token file has no refresh_token")
scope = d.get("scope", "").strip() or "offline_access https://outlook.office.com/SMTP.Send"
print(rt, scope)
PY
)

# Refresh token
RESP="$("$CURL" -sS -f \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "client_id=${CLIENT_ID}" \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "refresh_token=${REFRESH_TOKEN}" \
  --data-urlencode "scope=${SCOPE}" \
  "$TOKEN_ENDPOINT")"

# Rewrite token JSON atomically
TMP="$(mktemp /tmp/o365-token.XXXXXX.json)"
python3 - < "$SASL_PASSWD" <

Schedule Token Refresh (every 10 minutes)

# /etc/cron.d/o365-token-refresh
*/10 * * * * root /data/bin/refresh-o365-token.sh >> /var/log/refresh-o365-token.log 2>&1






Being a Postfix Admin – Useful Commands

tail -f /var/log/maillog                # monitor logs in real time
postfix flush                            # immediately flush/resend queued mail
postsuper -d ALL                         # delete ALL mail from queue (permanent!)
mailq                                    # show current queue contents

Example output when queue is empty:

Mail queue is empty

Special thanks again to Nathan for the O365 insights that made this migration possible.
Zwiegnet – Located between Madison and Milwaukee, Wisconsin since 2009