If you have a small Python script that logs in to Gmail or Microsoft 365 over IMAP with a username and password, there's a good chance it already gets AUTHENTICATE failed or Invalid credentials back, even with the right password. Both providers have moved IMAP to OAuth 2.0, and the IMAP side of that is a SASL mechanism called XOAUTH2.
This post covers why the password path closed, what XOAUTH2 actually sends over the wire, where the access token comes from, and how long it lives. The worked example uses mail-rule-digest, a stdlib-only Python CLI I wrote that filters a mailbox with a TOML rule file and writes a Markdown digest. Every command and output below comes from a real run on 2026-10-07 against a fake IMAP server on localhost, with a fake token. No real mailbox or credential was involved.
Why the password stopped working
Microsoft 365 / Exchange Online. Microsoft turned off Basic authentication for IMAP and POP in Exchange Online. The deprecation page says "All other cloud environments were subject to the October 1, 2022, date" and "Basic authentication is now disabled in all tenants" (learn.microsoft.com). For personal Outlook.com accounts the date was "September 16th, 2024 - Basic Authentication no longer available to access any Outlook account" (support.microsoft.com).
Google. Google wound down "less secure apps", meaning third-party apps that sign in with only a username and password. The Workspace Updates post first announced September 30, 2024, and its latest update reads: "Less Secure Apps will no longer be supported as of May 1, 2025" (workspaceupdates.googleblog.com). Google's pages don't all agree on the final date; the admin help page that the old support link redirects to still says March 14, 2025. Either way, it's in the past.
One exception remains for personal Google accounts: app passwords. Google's help page says "App passwords can only be used with accounts that have 2-Step Verification turned on", calls them not recommended, and excludes work and school accounts (support.google.com). If you control a personal account and just need a script working tonight, that's the short path. For Workspace or Microsoft 365 mailboxes, OAuth is the path.
What XOAUTH2 sends
XOAUTH2 is a SASL mechanism (SASL is RFC 4422; IMAP carries it with the AUTHENTICATE command, RFC 9051 section 6.2.2). The client sends one string, base64-encoded. Google documents it as:
base64("user=" {User} "^Aauth=Bearer " {Access Token} "^A^A")
where ^A is the byte 0x01 (developers.google.com). Microsoft's page has the same format: base64("user=" + userName + "^Aauth=Bearer " + accessToken + "^A^A") (learn.microsoft.com).
That's the whole credential. There is no signature and no nonce: the bearer token is the secret, so anything that logs this line logs the token. In Python it's one line, and imaplib does the base64 step for you:
def xoauth2_string(user: str, token: str) -> bytes:
return f"user={user}\x01auth=Bearer {token}\x01\x01".encode()
conn.authenticate("XOAUTH2", lambda _challenge: xoauth2_string(user, token))
Here is what my fake server received during the demo below, decoded with ^A for 0x01:
server: C: JGLC1 AUTHENTICATE XOAUTH2
server: C: dXNlcj1kZW1vQGV4YW1wbGUuY29tAWF1dGg9QmVhcmVyIHlhMjkuRkFLRS1MT0NBTC1ERU1PLVRPS0VOAQE=
server: decoded: user=demo@example.com^Aauth=Bearer ya29.FAKE-LOCAL-DEMO-TOKEN^A^A
The failure path has one quirk worth knowing. Gmail doesn't answer a bad token with NO straight away. It sends another + continuation carrying a base64 JSON error, and "The client sends an empty response ("\r\n") to the challenge containing the error message" (Google's XOAUTH2 page). Only then does the server send the tagged NO. If your callback blindly returns the token again, you resend the secret into an error path. The callback I use returns the token on the first challenge and b"" on any later one. Microsoft's page shows only a plain A01 NO AUTHENTICATE failed. for IMAP, so handling both shapes is the safe choice.
Where the token comes from, and how long it lives
The tool never runs a consent flow. You register an OAuth client with the provider, do the consent once with whatever library or CLI you like, and hand the script an access token.
Gmail. The scope "for IMAP, POP, and SMTP access is https://mail.google.com/" (Google's XOAUTH2 page). On the Gmail API scopes page it sits under "Restricted scopes", which require Google's restricted-scope app verification before a public app can use them. Two expiry details matter for a scheduled job:
- Access tokens: Google's OAuth overview says "Access tokens have limited lifetimes" and the token response carries
expires_in, the remaining lifetime in seconds. Read that field; don't hard-code an hour (developers.google.com). - Refresh tokens: a project with an external user type and "a publishing status of 'Testing' is issued a refresh token expiring in 7 days" (same page). If your cron job dies every week, this is usually why.
Microsoft 365 / Outlook.com. The delegated scope string on Microsoft's IMAP OAuth page is https://outlook.office.com/IMAP.AccessAsUser.All, and you add offline_access to get a refresh token. The IMAP host is outlook.office365.com, port 993 (learn.microsoft.com). For an unattended service there's an app-only route instead: the IMAP.AccessAsApp permission, a token requested with https://outlook.office365.com/.default, a service principal registered in Exchange, and an explicit mailbox permission grant. That one needs tenant admin consent.
Access tokens from the Microsoft identity platform get "a random value ranging between 60-90 minutes (75 minutes on average) as the default lifetime" (learn.microsoft.com). Also check IMAP itself: it's on by default in Exchange Online, but if security defaults are enabled, "POP3 and IMAP4 are automatically disabled" (the POP3/IMAP4 page above).
The practical consequence for both providers: a token pasted into an environment variable works for about an hour. A daily job needs something that refreshes. mail-rule-digest takes either IMAP_ACCESS_TOKEN (the token itself) or IMAP_TOKEN_COMMAND, a command whose stdout is a fresh access token, run at the start of every run.
The demo: a local fake server, a fake token
To get real output without touching a real mailbox, I wrote a ~100-line IMAP-over-TLS server in Python that only speaks XOAUTH2, accepts exactly one fake token, serves the five .eml test fixtures from the repo, and logs every command it receives. It refuses LOGIN the way a provider that disabled Basic auth would. Its certificate is signed by a throwaway local CA. All of this ran on Ubuntu under WSL2, Python 3.12.3, with the package installed from the repo at commit 698f960 (version 0.2.0).
The core of the fake server's AUTHENTICATE handling:
self.send("+ ")
decoded = base64.b64decode(self.rfile.readline().strip())
if decoded == f"user={USER}\x01auth=Bearer {TOKEN}\x01\x01".encode():
self.send(f"{tag} OK XOAUTH2 authentication successful")
else:
err = b'{"status":"400","schemes":"Bearer","scope":"https://mail.google.com/"}'
self.send("+ " + base64.b64encode(err).decode()) # Gmail-style error challenge
self.rfile.readline() # the client's empty response
self.send(f"{tag} NO [AUTHENTICATIONFAILED] Invalid credentials (Failure)")
Config: a rule file and an env file
The rules are the repo's example file, examples/rules.toml, unchanged:
[[rule]]
name = "Invoices over $100"
from = ["billing@acme.example", "*@invoices.example"] # glob, case-insensitive
subject = '(?i)\binvoice\b' # Python regex, searched
body_any = ["due", "overdue"] # case-insensitive substring
[rule.numbers.amount]
# The named group must have the same name as the field.
pattern = 'Total:\s*\$(?P<amount>[\d,]+(?:\.\d+)?)'
min = 100
[[rule]]
name = "Disk alerts at 90% or more"
from = "alerts@monitor.example"
[rule.numbers.disk]
pattern = '(?i)disk usage:?\s*(?P<disk>\d+)%'
min = 90
max = 100
[[rule]]
name = "Parcels out for delivery"
body_all = ["tracking number", "out for delivery"]
Connection settings live in the environment, never in that file. fake-token-cli is a tiny shell script standing in for a real OAuth helper that refreshes and prints a token:
$ cat mail.env
export IMAP_HOST=localhost IMAP_PORT=9993 IMAP_USER=demo@example.com
export IMAP_TOKEN_COMMAND=./fake-token-cli
$ ./fake-token-cli
ya29.FAKE-LOCAL-DEMO-TOKEN
$ . ./mail.env
First run: the certificate is checked
Before trusting the local CA, the run fails, which is what you want:
$ mail-rule-digest --rules rules.toml --days 3 --dry-run
error: cannot connect to localhost:9993: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1000)
Exit code 2, and the server log stays empty: the token was never sent. For the rest of the demo I point Python at the throwaway CA with export SSL_CERT_FILE=$PWD/ca.pem. Don't do that against real servers; it replaces the system trust store for that process.
Dry run
--days 3 because the fixtures are dated two days before the run, and IMAP SINCE works in whole days. --dry-run prints the digest and writes nothing. The [dry-run] line goes to stderr, which is why it shows up first here:
$ mail-rule-digest --rules rules.toml --days 3 --dry-run
[dry-run] 5 scanned, 3 matched; nothing written, nothing posted
# Mail digest 2026-10-07
3 matching message(s) across 3 rule(s).
## Invoices over $100 (1)
- **Invoice INV-1042 is ready** - billing@acme.example, 2026-10-05 09:15 - amount: 1,240.50
## Disk alerts at 90% or more (1)
- **\[ALERT\] disk usage 93% on web-1 @everyone** - alerts@monitor.example, 2026-10-05 12:05 - disk: 93
## Parcels out for delivery (1)
- **Your parcel is out for delivery 📦** - noreply@ship.example, 2026-10-05 11:45
The other two fixtures are an invoice under $100 and a newsletter, and neither matched. The server's view of the same run is the more interesting part:
server: C: JGLC0 CAPABILITY
server: C: JGLC1 AUTHENTICATE XOAUTH2
server: C: dXNlcj1kZW1vQGV4YW1wbGUuY29tAWF1dGg9QmVhcmVyIHlhMjkuRkFLRS1MT0NBTC1ERU1PLVRPS0VOAQE=
server: decoded: user=demo@example.com^Aauth=Bearer ya29.FAKE-LOCAL-DEMO-TOKEN^A^A
server: C: JGLC2 EXAMINE "INBOX"
server: C: JGLC3 SEARCH SINCE 05-Oct-2026
server: C: JGLC4 FETCH 1 (BODY.PEEK[])
server: C: JGLC5 FETCH 2 (BODY.PEEK[])
server: C: JGLC6 FETCH 3 (BODY.PEEK[])
server: C: JGLC7 FETCH 4 (BODY.PEEK[])
server: C: JGLC8 FETCH 5 (BODY.PEEK[])
server: C: JGLC9 LOGOUT
EXAMINE opens the folder read-only, and BODY.PEEK[] fetches a message without setting \Seen. No STORE, COPY, MOVE or EXPUNGE appears, because the code has none.
A bad token
An expired or wrong token goes through the error-challenge path described earlier:
$ IMAP_ACCESS_TOKEN=ya29.EXPIRED-TOKEN mail-rule-digest --rules rules.toml --days 3 --dry-run
error: IMAP error: [AUTHENTICATIONFAILED] Invalid credentials (Failure)
server: C: EAIP1 AUTHENTICATE XOAUTH2
server: C: dXNlcj1kZW1vQGV4YW1wbGUuY29tAWF1dGg9QmVhcmVyIHlhMjkuRVhQSVJFRC1UT0tFTgEB
server: decoded: user=demo@example.com^Aauth=Bearer ya29.EXPIRED-TOKEN^A^A
server: C: '' (empty response to the error challenge)
server: C: EAIP2 LOGOUT
The token went out once, the client answered the JSON challenge with an empty line, and the error message the user sees doesn't contain the token. When IMAP_ACCESS_TOKEN is set it wins over IMAP_TOKEN_COMMAND, which is why this run used the bad token.
The old password path, for comparison
$ env -u IMAP_TOKEN_COMMAND IMAP_PASSWORD=hunter2 mail-rule-digest --rules rules.toml --days 3 --dry-run
error: IMAP error: b'[AUTHENTICATIONFAILED] password login disabled, use XOAUTH2'
The server log shows LOGIN demo@example.com "hunter2". Plain LOGIN puts the password on the wire inside TLS, and a server that has switched Basic auth off just says no. (The b'...' in that message is imaplib handing back bytes on the LOGIN path; cosmetic, but it's a real difference between the two paths.)
Digest file and webhook
Without --dry-run the tool writes digest-YYYY-MM-DD.md, and --webhook also posts it to $WEBHOOK_URL. Here the webhook is a second local HTTPS server that prints what it receives:
$ export WEBHOOK_URL=https://localhost:8443/hook
$ mail-rule-digest --rules rules.toml --days 3 --webhook
5 scanned, 3 matched -> digest-2026-10-07.md
webhook: HTTP 200
$ ls digest-*.md
digest-2026-10-07.md
The receiver got this (the text value is truncated here; it's the same Markdown as above):
hook: POST /hook application/json
{
"text": "# Mail digest 2026-10-07\n\n3 matching message(s) across 3 rule(s).\n\n## Invoices over $100 (1)\n\n- **Invoice INV-1042 is ready** - billing@acme.example, 2026-10-05 09:15 - amount: 1,240.50\n\n..."
}
{"text": ...} is the Slack incoming-webhook shape. For a discord.com URL the tool sends {"content": ..., "allowed_mentions": {"parse": []}} instead, cut to Discord's 2,000-character limit. Mentions are disabled on purpose: look at the disk alert subject above. It contains @everyone, and without that setting a forwarded email subject could ping a whole Discord channel. I didn't run the Discord branch here because it needs the real host; the repo's tests cover it.
A plain-HTTP webhook URL is refused. The digest file is still written first, so the exit code is 1, not 2:
$ WEBHOOK_URL=http://localhost:8443/hook mail-rule-digest --rules rules.toml --days 3 --webhook
5 scanned, 3 matched -> digest-2026-10-07.md
error: webhook failed: webhook URL must use https
Safety notes
Never put tokens in the config file. The rule file holds rules only. Credentials come from IMAP_PASSWORD, IMAP_ACCESS_TOKEN or IMAP_TOKEN_COMMAND, and there are no command-line flags for them, since flags end up in shell history and in ps output. The token command runs without a shell (split like a POSIX command line), with a 30-second timeout. If it fails, the error names the exit status only; neither its stdout nor its stderr is echoed, because either could contain the token. The refresh token itself should live wherever your OAuth helper keeps it, ideally the OS keychain, not next to the cron entry.
Read-only fetch. EXAMINE plus BODY.PEEK[], as the server log showed. The tool never sends, deletes, moves or flags mail. The scope is another matter: Gmail's https://mail.google.com/ and Microsoft's IMAP.AccessAsUser.All both grant full mailbox access. Read-only is a property of this code, not of the token, so treat the token as a full-access credential.
Certificate verification. This one surprised me when I first checked it. imaplib.IMAP4_SSL(host, port) with no ssl_context does not verify the server certificate. On the same Python 3.12.3, against the same fake server and with no trust for its CA, the bare call connected, while ssl_context=ssl.create_default_context() failed with CERTIFICATE_VERIFY_FAILED. An unverified connection hands your bearer token to whoever answers on port 993. Always pass a verifying context, as the first demo run showed the tool doing.
The digest is as private as the mailbox. It contains subjects and sender addresses. A webhook URL is also a secret, since anyone holding it can post to your channel.
What the tool does not do. It doesn't run the OAuth consent flow, store or refresh tokens, or ship a client ID. It only speaks IMAP over TLS on port 993: no STARTTLS, no POP3, no Gmail API. It keeps no state between runs, so running twice in a day produces the same digest twice. And it has not been run against live Gmail or Microsoft servers by its own test suite; the 38 tests use fake servers and fixtures, as this post does. If a provider rejects your token, you'll see the server's IMAP error: ... text, without the token in it.
Sources
All fetched 2026-10-07.
- Google, "OAuth 2.0 Mechanism" (XOAUTH2 format, error challenge, scope): https://developers.google.com/gmail/imap/xoauth2-protocol
- Google, "Using OAuth 2.0 to Access Google APIs" (token lifetime, 7-day Testing refresh tokens): https://developers.google.com/identity/protocols/oauth2
- Google, Gmail API scopes (restricted scopes): https://developers.google.com/workspace/gmail/api/auth/scopes
- Google Workspace Updates, "Winding down Google Sync and Less Secure Apps support": https://workspaceupdates.googleblog.com/2023/09/winding-down-google-sync-and-less-secure-apps-support.html
- Google, "Sign in with app passwords": https://support.google.com/accounts/answer/185833
- Microsoft, "Authenticate an IMAP, POP or SMTP connection using OAuth": https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth
- Microsoft, "Deprecation of Basic authentication in Exchange Online": https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/deprecation-of-basic-authentication-exchange-online
- Microsoft, "POP3 and IMAP4 in Exchange Online": https://learn.microsoft.com/en-us/exchange/clients-and-mobile-in-exchange-online/pop3-and-imap4/pop3-and-imap4
- Microsoft, "Access tokens in the Microsoft identity platform": https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens
- Microsoft Support, "Modern authentication methods now needed to continue syncing Outlook Email in non-Microsoft email apps": https://support.microsoft.com/en-us/office/modern-authentication-methods-now-needed-to-continue-syncing-outlook-email-in-non-microsoft-email-apps-c5d65390-9676-4763-b41f-d7986499a90d
- RFC 4422 (SASL): https://www.rfc-editor.org/rfc/rfc4422 and RFC 9051 (IMAP4rev2): https://www.rfc-editor.org/rfc/rfc9051
The tool, its tests and the example rules: https://github.com/mahirhir/mail-rule-digest
Top comments (0)