Microsoft killed Basic Auth for IMAP4, POP3, EWS, EAS and remote PowerShell across the bulk of tenants in October 2022, then finished the job by permanently retiring Basic SMTP AUTH in 2025. Google spent years narrowing "less secure app" access, and Workspace admins can now disable app passwords org-wide with a single toggle. If your mail client still opens port 993 and logs in with a stored password, that code has a hard expiry date set by someone else's roadmap.
The replacement is not "another password you paste once." XOAUTH2 carries a short-lived bearer token that you have to mint, cache, and refresh out-of-band — and the two providers that matter, Google and Microsoft 365, disagree on flow, scopes, and lifetime. By the end of this you can mint a token from a refresh token, build the SASL string byte-for-byte, authenticate over both IMAP and SMTP, refresh cleanly before you eat a 401, and decode the base64 error challenges that make bad tokens look like server bugs.
The deprecation timeline you're actually racing
These are dated cutoffs, not opportunistic cleanup, and they differ per provider.
- Microsoft 365 — Basic Auth disabled for EAS, EWS, IMAP4, POP3, and remote PowerShell on most tenants in October 2022. SMTP AUTH got a stay of execution and was permanently retired in 2025. There is no re-enable switch anymore. For unattended access to many mailboxes you use the Client Credentials (app-only) flow, not a password.
- Google / Gmail & Workspace — "less secure apps" is gone and app passwords are being narrowed. Personal accounts can still create app passwords, but only with 2-Step Verification on; Workspace admins can disable app passwords for the whole org. OAuth is the supported path for anything that isn't a human typing into the official client.
- Yahoo, Fastmail, and a long tail of smaller hosts — still issue app passwords. Do not assume every provider forces OAuth. Part of the job is knowing which of your target providers *mandate* OAuth versus which merely *offer* it, because your code needs both branches for a while.
The practical read: build for OAuth now on Google and M365, keep app-password support only for providers that still require it, and stop treating "we'll migrate later" as a plan.
XOAUTH2 vs OAUTHBEARER: the SASL layer
Both XOAUTH2 and OAUTHBEARER are SASL mechanisms whose credential is a bearer access token instead of a password. They sit exactly where PLAIN or LOGIN used to sit in the IMAP/SMTP handshake — same slot, different payload.
XOAUTH2 is Google's original, de-facto mechanism. It's the most widely accepted one in the wild and your safe fallback. OAUTHBEARER is the standardized successor, RFC 7628, and it's what you should prefer when the server advertises it.
The XOAUTH2 initial client response is the base64 encoding of this exact byte sequence, where ^A is the control byte 0x01 (Ctrl+A), not the literal characters caret-A:
[email protected]^Aauth=Bearer ya29.a0Af...^A^ANote the ending: auth=Bearer <token> followed by two 0x01 bytes. Drop one separator and you get an authentication failure indistinguishable from a bad token. OAUTHBEARER wraps the same idea in a GS2 header (n,[email protected],) plus a slightly richer key/value block — but the shape is identical: user identity plus a bearer token, 0x01-delimited.
Prefer OAUTHBEARER where CAPABILITY / EHLO advertises it, fall back to XOAUTH2 otherwise. As of 2026, Gmail and Outlook both accept XOAUTH2 universally; OAUTHBEARER is more common server-side than client-side.
Getting a token: the two flows that matter
You need exactly two OAuth flows. Pick by asking whose mailbox you're touching.
Flow 1 — Authorization Code + refresh (delegated, per-user). A human consents once in a browser; you request offline_access (Microsoft) or access_type=offline (Google) and get a refresh token you store. This is the flow for user mailboxes on both Gmail and M365. The refresh token is the long-lived secret; access tokens are disposable.
Flow 2 — Client Credentials (M365 app-only). One registered app authenticates *as itself* and reads or sends on behalf of many mailboxes with no user present. There is no refresh token — you re-mint an access token from the client secret or certificate whenever you need one. This is the M365 pattern for backend services, migration tools, and shared-mailbox automation.
The trap most teams hit: Gmail has no equivalent app-only IMAP flow. A Google service account cannot IMAP into arbitrary Workspace mailboxes with client credentials the way an M365 app can. For Google you either collect a per-user refresh token via the Authorization Code flow, or you use domain-wide delegation to let a service account impersonate users — a separate, admin-gated mechanism.
Provider specifics: scopes, endpoints, token lifetimes
The values you need, side by side.
- Token endpoint:
https://oauth2.googleapis.com/token - Scope for full IMAP + SMTP:
https://mail.google.com/— this single scope covers both; there are no separate IMAP/SMTP Gmail scopes. - Refresh tokens are revoked on password change, on consent revoke, and after 6 months of inactivity. Apps still in "Testing" publishing status get refresh tokens that expire in 7 days — the classic "works in dev, dies in prod" bug.
Microsoft 365
- Token endpoint:
https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token— use your tenant GUID, notcommon, for app-only. - Delegated scopes:
https://outlook.office365.com/IMAP.AccessAsUser.All,https://outlook.office365.com/SMTP.Send,https://outlook.office365.com/POP.AccessAsUser.All, plusoffline_accessto actually receive a refresh token. - App-only scope:
https://outlook.office365.com/.default, after granting theIMAP.AccessAsApp/SMTP.SendAsAppapplication permissions with tenant admin consent.
Access tokens last roughly 3600 seconds on both providers. Treat that as a ceiling, not a promise, and read the actual expires_in from the token response.
Wire it up: IMAP and SMTP with a live token
Start with the raw bytes so you can debug without a library in the way. Mint the token, then hand-drive Gmail's IMAP endpoint with openssl:
# 1. exchange a refresh token for an access token (Google)
curl -s https://oauth2.googleapis.com/token \
-d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRET \
-d refresh_token=$REFRESH_TOKEN \
-d grant_type=refresh_token | jq -r .access_token
# 2. talk to IMAPS by hand (paste the b64 XOAUTH2 string after AUTHENTICATE)
openssl s_client -connect imap.gmail.com:993 -crlf -quiet
a01 CAPABILITY
a02 AUTHENTICATE XOAUTH2 dXNlcj1hbGljZUBleGFtcGxlLmNvbQFhdXRoPUJlYXJlciB5YTI5...For SMTP submission, EHLO first and confirm the server advertises 250-AUTH ... XOAUTH2, then:
AUTH XOAUTH2 dXNlcj1hbGljZUBleGFtcGxlLmNvbQFhdXRoPUJlYXJlciB5YTI5...Building that base64 string correctly is where people slip. The 0x01 separators are literal control bytes, not text:
import base64, imaplib
def xoauth2_string(user, access_token):
raw = f"user={user}\x01auth=Bearer {access_token}\x01\x01"
return base64.b64encode(raw.encode()).decode()
M = imaplib.IMAP4_SSL("imap.gmail.com", 993)
auth = xoauth2_string("[email protected]", access_token)
M.authenticate("XOAUTH2", lambda _: auth.encode())
M.select("INBOX")For SMTP in Node, nodemailer speaks XOAUTH2 natively — pass auth: { type: 'OAuth2', user, accessToken } and let it build the SASL string. Keep the refresh logic in your own code so you control caching rather than letting the library silently re-mint.
When it fails: reading the error challenge
Here's the behavior that turns a five-minute bug into an afternoon: on a bad token the server does not immediately say NO. It sends a base64-encoded JSON error challenge and expects you to reply with an empty continuation line before it finally reports the failure. Libraries that mishandle this hang, or surface a useless "authentication failed."
Decode the challenge and it tells you exactly what's wrong:
echo 'eyJzdGF0dXMiOiI0MDAiLCJzY2hlbWVzIjoiQmVhcmVyIiwic2NvcGUiOiJodHRwczovL21haWwuZ29vZ2xlLmNvbS8ifQ==' | base64 -d
# {"status":"400","schemes":"Bearer","scope":"https://mail.google.com/"}The failures worth naming, because you *will* hit all of them:
- `invalid_grant` — the refresh token was rotated or revoked (password change, consent withdrawal, 7-day test-app expiry). Do not retry; force re-consent.
- `insufficient_scope` — you got a token, but not for
https://mail.google.com/or the rightIMAP.AccessAsUser.All. Re-check the scope you requested against the one you're using. - `400` with a valid-looking token — clock skew. If the host's time drifts,
expvalidation fails or the token reads as "not yet valid." Run NTP. - Mechanism not advertised —
CAPABILITY/EHLOdoesn't listXOAUTH2. Either OAuth isn't enabled server-side, or you're pointed at a legacy endpoint where Basic Auth is still the only thing on. - M365 mailbox outside the application access policy — the app-only token is valid but the target mailbox isn't in scope. This one succeeds at the token mint and fails only at IMAP AUTH, so it looks like a token bug when it's a policy bug.
Running the server side (self-hosted, Dovecot + Postfix)
If you terminate IMAP and SMTP yourself — where evilmail-style infrastructure lives — you're on the validation side of this. Dovecot supports both xoauth2 and oauthbearer SASL mechanisms:
# dovecot: 10-auth.conf
auth_mechanisms = plain login xoauth2 oauthbearer
# dovecot: enable the oauth2 auth backend
passdb {
driver = oauth2
mechanisms = xoauth2 oauthbearer
args = /etc/dovecot/dovecot-oauth2.conf.ext
}The rule that keeps you safe: validate the token, don't trust the string. Either validate the JWT locally against the IdP's JWKS (tokeninfo_url / openid_configuration_url, with signature plus aud/iss checks) or call the IdP's introspection endpoint. An unverified bearer string is just a password with extra steps — worse, one you didn't issue. Postfix defers to Dovecot SASL to broker all of this, so set smtpd_sasl_type = dovecot and let Dovecot own the token check.
Operational checklist
- Store refresh tokens and client credentials encrypted; never store access tokens. They expire in an hour and leak like water.
- Refresh proactively at ~80% of lifetime, on a timer — not reactively on a 401. Chasing 401s means every mailbox eats a failed connection first.
- Cache tokens per-mailbox with jitter. Without it, a thousand workers hit the token endpoint in the same second after a restart and you rate-limit yourself.
- On `invalid_grant`, force re-consent — do not retry. The refresh token is dead; retrying just burns quota and floods logs.
- Log the decoded error challenge, not "auth failed." The base64 JSON is the actual diagnosis.
- Pin the token endpoint and verify TLS. Bearer tokens over anything but validated TLS is a credential-theft primitive.
- Scope every M365 app with `New-ApplicationAccessPolicy`. Until you do, the app can read every mailbox in the tenant.
- Keep an OAUTHBEARER-to-XOAUTH2 fallback keyed off advertised capability, and hold app-password support only for the providers that still demand it.


