OAuth2 authentication proxy for IMAP, SMTP submission and ManageSieve
Install · Configuration · Operations · Security · Contributing · Changelog
mail-auth-proxy sits in front of your mail server, terminates TLS and checks every login
before the backend sees it. OAuth2 tokens (XOAUTH2, OAUTHBEARER) are validated locally
and then passed unchanged to the backend. Passwords (PLAIN, LOGIN) get in only through
the legacy gate, which is off by default. After the login the proxy relays the session; it
does not implement IMAP or SMTP beyond the authentication preamble and a command guard
that keeps IMAP and ManageSieve sessions logged in. It is one static Rust binary with one
configuration file.
Mail clients have moved to OAuth2, and providers are switching off password logins for IMAP and SMTP. On your own mail server you want one public endpoint that accepts only valid tokens, while a few legacy systems (scanners, monitoring, mailflow checks) still need passwords from known networks. mail-auth-proxy serves both on the same IP and port and accepts a password only where a rule allows it.
- The source IP and TLS server name decide which mechanisms the connection offers.
- The proxy reads
AUTHENTICATE/AUTHand checks the credential: a token against the issuer's keys, a password against the legacy gate. Nothing reaches the backend before that check has passed. - It logs in to the backend with the same credential, passing the real client address
(PROXY protocol v2 for Dovecot, XCLIENT for Postfix), and relays the session until
either side closes. IMAP and ManageSieve commands pass a guard that keeps
UNAUTHENTICATEand a second login from the backend.
Important
The backend must still validate the forwarded token itself; the proxy does not replace that check.
Token validation
- The backend receives the client's own token: no master password, no shared secret, no introspection call per login.
- Local JWT check against each issuer's JWKS: signature,
iss,aud,exp/nbf, access token only (Keycloaktypor RFC 9068at+jwt),email_verified, optional client allowlist. - The algorithm comes from the JWKS key, never from the token header; each key is valid only
for the issuer that published it.
alg=noneand HS* forgeries fail. - The backend login is the validated identity claim (default
email), never the user name the client typed.
Password gate (optional, off by default)
- Connections without a matching rule are OAuth-only.
- Rules per source network, TLS server name, protocol, mechanism and user; allowed domains; account check via the doveadm HTTP API; per-account throttle.
- Refused and wrong passwords get the same reply with similar timing; the log keeps the reasons apart. Passwords are never logged, only a per-process keyed fingerprint.
Protocols
- IMAPS on 993, SMTP submission on 587 (STARTTLS) and optionally 465 (implicit TLS, RFC 8314), ManageSieve on 4190 (STARTTLS).
- A profile per backend: STARTTLS or implicit TLS, the client address via PROXY protocol v2 or XCLIENT, the token forwarded as XOAUTH2 or OAUTHBEARER (RFC 7628).
- Several mail systems behind one endpoint: routes choose the backend by the domain of the identity or login; a backend can be a pool of addresses with failover and health checks.
- Backend TLS is always verified; there is no switch to turn it off.
Operations
- One fixed
authresultlog line per login, made for log-based blocking; CrowdSec parser and scenarios in contrib/crowdsec. - Optional Prometheus metrics, systemd unit with
Type=notify,SIGHUPreloads the configuration, certificates and JWKS without dropping connections. - Connection caps, per-IP pre-auth cap, a pre-auth time budget and size limits on lines, literals, tokens and passwords.
- Built-in blocking of source addresses after too many failed logins (tokens and passwords, any account), on by default; outages never count.
Distribution
- One static
x86_64-unknown-linux-muslbinary;.deb(APT repository),.rpmand.tar.gzfor each release. SHA256SUMSsigned with the release key; hermetic builds in a digest-pinned container, checked in CI by building twice and comparing the bytes.
Requirements: Linux on x86_64; a mail backend that validates OAuth2 bearer tokens
itself, for example Dovecot 2.4 with the oauth2 passdb and Postfix using Dovecot SASL
(setup); an OAuth2 / OpenID Connect provider that
publishes a JWKS and puts your mail audience into access tokens (tested with Keycloak,
setup); a TLS certificate for every name clients use.
1. Install from the APT repository (Debian, Ubuntu):
sudo install -d -m755 /etc/apt/keyrings
curl -fsSL https://apt.nk-it.cloud/gpg.key \
| sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/nk-it-cloud.gpg
gpg --show-keys /etc/apt/keyrings/nk-it-cloud.gpg # A66E 54ED 9E75 BF3D E610 2ED5 AE60 35D7 D6D3 1EB4
echo "deb [signed-by=/etc/apt/keyrings/nk-it-cloud.gpg] https://apt.nk-it.cloud/apt stable main" \
| sudo tee /etc/apt/sources.list.d/nk-it-cloud.list
sudo apt update
sudo apt install mail-auth-proxyCompare the fingerprint before apt update. The package installs the binary, the systemd
unit, the system user mail-auth-proxy and a commented example configuration; the service is not
started until you enable it.
RHEL-compatible systems (RPM), release archive
There is no DNF repository. Download the RPM, SHA256SUMS, SHA256SUMS.asc and
release-signing-key.asc from the
release page, verify them as
described in INSTALL.md, then:
sudo rpm --import release-signing-key.asc
rpm -K mail-auth-proxy-X.Y.Z-1.x86_64.rpm # expect "digests signatures OK"
sudo dnf install ./mail-auth-proxy-X.Y.Z-1.x86_64.rpmFor other distributions each release has a .tar.gz with the static binary, the example
configuration and the systemd unit; see INSTALL.md.
2. Configure /etc/mail-auth-proxy/config.toml. A minimal OAuth-only setup for reading
and sending mail, IMAP and SMTP submission:
config_version = 2
[server]
hostname = "mail.example.org"
[tls]
cert = "/etc/mail-auth-proxy/tls/fullchain.pem"
key = "/etc/mail-auth-proxy/tls/privkey.pem"
[imap]
listen = "0.0.0.0:993"
# client_ip = "proxy_v2" needs a Dovecot listener with haproxy = yes; without it every login fails
backend = { address = "192.0.2.10:10993", verify_name = "imap.example.org", client_ip = "proxy_v2" }
[submission]
listen = "0.0.0.0:587"
# client_ip = "xclient" needs the proxy's address in Postfix's smtpd_authorized_xclient_hosts
backend = { address = "192.0.2.10:587", verify_name = "smtp.example.org", client_ip = "xclient" }
[[oauth.issuers]]
issuer = "https://sso.example.org/realms/mail"
jwks_url = "https://sso.example.org/realms/mail/protocol/openid-connect/certs"
audiences = ["mail"]
token_type = "keycloak"Add submission.implicit_tls_listen for port 465 and [sieve] for ManageSieve
(server-side filters). Every key is described
in docs/configuration.md; the shipped file
(examples/config.example.toml) shows all sections. The
service runs as the system user mail-auth-proxy, so configuration,
certificate and key must be root:mail-auth-proxy, mode 0640
(permissions).
3. Check and start:
sudo mail-auth-proxy --check-config /etc/mail-auth-proxy/config.toml # lists every problem at once
sudo systemctl enable --now mail-auth-proxy
journalctl -u mail-auth-proxy -n 20 # expect "imap listener up" and "submission listener up"Log in with a mail client and look for authresult result="ok" in the journal. After a
configuration change or certificate renewal, systemctl reload mail-auth-proxy loads it
without closing open connections; only a change of the listeners or the metrics endpoint
needs a restart.
Build from source
cargo build --release --lockedThe toolchain is pinned in rust-toolchain.toml; the minimum supported Rust version is
1.88. Building needs a C compiler for aws-lc-rs; there is no OpenSSL dependency. Tests,
checks and the source layout are in CONTRIBUTING.md.
Every login attempt writes one authresult line to the journal:
WARN authlog: authresult result="fail" proto="imap" scope="external" mech=PLAIN user=a@gone.test peer=198.51.100.7 reason="unknown_domain" pwfp="5d0f1c7a92b3e416" rule="partner" listener="imap" backend="" tls="TLSv1.3/TLS13_AES_256_GCM_SHA384"
Its fields and reason values are a stable interface for log parsers (CrowdSec, Wazuh and the like) and are described in docs/operations.md. A backend outage is answered with a retry-later reply and never logged as a failed login, so an outage cannot turn into bans of legitimate users.
Prometheus metrics
Off by default; enable with enabled = true and a listen address in [metrics]. The endpoint has no
authentication, so keep it on loopback or a management network.
| Metric | What it counts |
|---|---|
mail_auth_proxy_auth_attempts_total |
evaluated credentials by protocol, scope, mechanism and result |
mail_auth_proxy_auth_refusals_total |
refused credentials by authresult reason |
mail_auth_proxy_preauth_aborts_total |
connections that ended before a credential |
mail_auth_proxy_token_validations_total |
local JWT validations by result |
mail_auth_proxy_connections_total |
connections admitted past the limits |
mail_auth_proxy_connections_rejected_total |
connections closed at accept by a limit |
mail_auth_proxy_ratelimit_blocks_total |
connections closed at accept because the source is blocked after failed logins |
mail_auth_proxy_ratelimit_active_blocks |
sources blocked now |
mail_auth_proxy_active_connections |
admitted connections currently open |
mail_auth_proxy_backend_errors_total |
backend or account-check outages while a client waited |
mail_auth_proxy_upstream_forwards_total |
sessions spliced to a backend |
mail_auth_proxy_backend_login_duration_seconds |
histogram of successful backend logins |
mail_auth_proxy_jwks_last_success_timestamp_seconds |
last JWKS fetch with usable keys, per issuer |
mail_auth_proxy_tls_cert_expiry_timestamp_seconds |
expiry of each certificate in use (label cert) |
The complete list with labels, including mail_auth_proxy_build_info,
process_start_time_seconds, the JWKS refresh failures and skipped keys, the legacy-gate and the other
rate-limit counters and the per-backend health and failover metrics, is in
docs/operations.md.
| Document | Content |
|---|---|
| INSTALL.md | packages, verification, permissions, certificates, upgrade, removal |
| docs/configuration.md | every configuration key, default and check |
| docs/backend-dovecot-postfix.md | Dovecot 2.4 and Postfix behind the proxy |
| docs/idp-keycloak.md | Keycloak realm and mail clients |
| docs/architecture.md | token validation, legacy gate, limits, timeouts |
| docs/protocols.md | IMAP, SMTP and ManageSieve dialogs and replies |
| docs/operations.md | logs, metrics, signals, troubleshooting |
| SECURITY.md | trust boundaries, threat model, operator duties, reporting |
The full index is docs/README.md.
The proxy logs in to any IMAP, submission and ManageSieve server that validates the
forwarded token itself and speaks the standard dialogs; the backend profile
(tls, client_ip, auth_forward) adapts the connection to it.
| Backend | Status | Setup |
|---|---|---|
| Dovecot 2.4 (IMAP, ManageSieve) and Postfix with Dovecot SASL (submission) | supported: runs in production | docs/backend-dovecot-postfix.md, example |
| Stalwart | untested example, written from the Stalwart documentation | docs/backend-stalwart.md, example |
- At most
limits.max_auth_attemptsauthentication attempts per connection (default 3). - After login the idle limit and the session lifetime limit are off by default
(
[session]); TCP keepalive is on. A session does not end when its token expires. - ManageSieve
LOGINis not supported. - After login, IMAP and ManageSieve clients cannot use
UNAUTHENTICATE, a second login,STARTTLSor IMAPCOMPRESS; an IMAP literal costs one round trip. - The SMTP
EHLOlist is the backend's as the proxy's own probe sees it, cached forsubmission.capability_cache_secs; extensions the proxy does not handle are left out. - Connections closed by a connection limit, a failed-login block or a timeout get no
421/BYE. - The failed-login block counts per source address (IPv6 per /64, set by
limits.ipv6_source_prefix): users behind one NAT or webmail server share it unless that address is exempt. - Legacy rules trust the source address: the proxy must see real client addresses (no SNAT
or load balancer in front). A rule with
snineeds SNI, which clients connecting by IP address do not send. - Backends other than Dovecot and Postfix have not been tested yet (Stalwart: an untested example).
The complete list is in docs/protocols.md.
- nginx, freenginx or Angie mail proxy with
auth_http: the decision is delegated to an HTTP script you write, and the backend login uses LOGIN/PLAIN, so a token has to be turned into a password or a master password. No ManageSieve. - Dovecot as a proxy: can validate tokens itself (oauth2 passdb) and proxy IMAP,
submission and ManageSieve. Forwarding the user's own token through a Dovecot proxy
(
proxy_mech) is documented for recent 2.4 releases; we have not tested it. - Stalwart: a mail server with OAuth support of its own; its migration proxy forwards tokens without checking them.
If your mail server already covers your case, you do not need mail-auth-proxy.
Note
Pre-1.0. The proxy runs in production in front of Dovecot and Postfix with Thunderbird,
Android and webmail clients. Before 1.0, a minor release (0.x → 0.y) may change the
public interface (configuration keys, the authresult line, metrics, command line); a
patch release does not. Every such change is listed in CHANGELOG.md.
Please report vulnerabilities privately, through GitHub's private vulnerability reporting or by email to security@nk-it.cloud, not in public issues. The security model, threat model and operator duties are in SECURITY.md.
MIT, see LICENSE.