Fix IMAP Authentication Failed Error in Python
The attachment-intake script stops at login with one of these, although the same address and password work in the webmail interface:
imaplib.IMAP4.error: b'[AUTHENTICATIONFAILED] Invalid credentials (Failure)'
imaplib.IMAP4.error: b'[ALERT] Application-specific password required: https://support.google.com/accounts/answer/185833 (Failure)'
imaplib.IMAP4.error: b'LOGIN failed.'
imaplib.IMAP4.error: b'AUTHENTICATE failed.'
The last two are typical of Microsoft 365, the first two of Gmail, and generic variants come from every other provider. None of them means the script's IMAP code is wrong.
Root Cause
A browser login and an IMAP login are different authentication paths, and providers treat them differently. Mailboxes with multi-factor authentication cannot complete a second factor over IMAP's plain LOGIN command, so providers either reject the account password outright or require a separate app password generated for that one client. Large providers have gone further and disabled password-based ("basic") authentication for IMAP entirely: Microsoft 365 accepts only OAuth 2.0 tokens through the XOAUTH2 mechanism, and Google accepts app passwords only on accounts with 2-step verification and increasingly steers organisations toward OAuth. Beyond those policy reasons, three mundane causes produce the same message: IMAP access switched off for the mailbox or tenant, a username that is not the full address the server expects, and a password containing characters mangled by shell quoting or a .env parser.
Minimal Diagnostic
Ask the server which authentication mechanisms it offers, check the credentials are loaded as intended without printing them, and try the login with the exact server response captured.
# pip install python-dotenv
import imaplib
import os
from dotenv import load_dotenv
def diagnose_login() -> None:
load_dotenv()
host = os.environ.get("MAIL_HOST", "")
user = os.environ.get("MAIL_USER", "")
password = os.environ.get("MAIL_PASSWORD", "")
print(f"host={host!r} user={user!r} password: {len(password)} chars, "
f"leading/trailing space={password != password.strip()}, quotes={password[:1] in {chr(34), chr(39)}}")
try:
conn = imaplib.IMAP4_SSL(host, 993, timeout=20)
except OSError as exc:
raise SystemExit(f"cannot reach {host}:993 — network or host problem, not authentication: {exc}")
caps = conn.capabilities
print("server capabilities:", " ".join(c for c in caps if c.startswith("AUTH=") or c in ("IMAP4rev1", "IMAP4rev2")))
print("LOGINDISABLED advertised:", "LOGINDISABLED" in caps)
try:
typ, data = conn.login(user, password)
print("login OK:", typ, data)
except imaplib.IMAP4.error as exc:
print("login failed, server said:", exc)
finally:
try:
conn.logout()
except imaplib.IMAP4.error:
pass
if __name__ == "__main__":
diagnose_login()
host='outlook.office365.com' user='[email protected]' password: 16 chars, leading/trailing space=False, quotes=False
server capabilities: IMAP4rev1 AUTH=PLAIN AUTH=XOAUTH2
LOGINDISABLED advertised: False
login failed, server said: b'LOGIN failed.'
The server advertises AUTH=XOAUTH2, and plain login fails with correct credentials: basic authentication is disabled for this tenant. On Gmail the output would include the Application-specific password required alert, which names the fix directly.
Fix 1: Use an App Password (Gmail and Other 2FA Mailboxes)
For Google Workspace or Gmail accounts with 2-step verification, and for providers such as Fastmail, iCloud and Yahoo that use the same model, generate an app password in the account's security settings and use it instead of the account password. Store it without the display spaces some providers show.
# pip install python-dotenv
import imaplib
import os
from dotenv import load_dotenv
def login_with_app_password() -> imaplib.IMAP4_SSL:
load_dotenv()
host = os.environ["MAIL_HOST"] # e.g. imap.gmail.com
user = os.environ["MAIL_USER"].strip() # changed: full address, trimmed
app_password = os.environ["MAIL_APP_PASSWORD"].replace(" ", "") # changed: app password, spaces removed
conn = imaplib.IMAP4_SSL(host, 993, timeout=30)
try:
conn.login(user, app_password)
except imaplib.IMAP4.error as exc:
conn.logout()
raise RuntimeError(f"app password rejected for {user}: {exc}") from exc
return conn
Check two account-level settings when an app password is still rejected: IMAP access must be enabled in the mail settings, and in Google Workspace the administrator must allow IMAP and must not restrict access to OAuth-only clients. App passwords are revoked when the account password changes, which is a common reason a job that ran for months suddenly fails.
Fix 2: OAuth 2.0 with XOAUTH2 (Microsoft 365)
Exchange Online requires an OAuth access token for IMAP. Register an application in Microsoft Entra ID, grant it the IMAP.AccessAsApp application permission for Office 365 Exchange Online, register its service principal in Exchange, and grant it access to the one mailbox it needs. Then request a token with MSAL and authenticate with XOAUTH2:
# pip install msal python-dotenv
import imaplib
import os
import msal
from dotenv import load_dotenv
def xoauth2_login() -> imaplib.IMAP4_SSL:
load_dotenv()
tenant, client_id = os.environ["AZURE_TENANT_ID"], os.environ["AZURE_CLIENT_ID"]
secret, mailbox = os.environ["AZURE_CLIENT_SECRET"], os.environ["MAIL_USER"]
app = msal.ConfidentialClientApplication(
client_id, authority=f"https://login.microsoftonline.com/{tenant}", client_credential=secret)
result = app.acquire_token_for_client(scopes=["https://outlook.office365.com/.default"]) # changed
if "access_token" not in result:
raise RuntimeError(f"token request failed: {result.get('error')}: {result.get('error_description')}")
auth_string = f"user={mailbox}\x01auth=Bearer {result['access_token']}\x01\x01" # changed
conn = imaplib.IMAP4_SSL("outlook.office365.com", 993, timeout=30)
try:
conn.authenticate("XOAUTH2", lambda _: auth_string.encode()) # changed
except imaplib.IMAP4.error as exc:
conn.logout()
raise RuntimeError(f"XOAUTH2 rejected for {mailbox}: {exc}") from exc
return conn
The .default scope on the Exchange resource asks for the application permissions already granted to the app. AUTHENTICATE failed at this stage — with a valid token — almost always means the Exchange service principal was not registered or not given mailbox permission; those steps happen in Exchange Online PowerShell (New-ServicePrincipal and Add-MailboxPermission), not in the Azure portal. For new work, the Microsoft Graph API avoids IMAP entirely and is simpler to scope — see download attachments from Microsoft 365 with the Graph API.
Fix 3: Username, Encoding and IMAP Access Settings
When the provider does accept passwords, the remaining causes are configuration. Normalise what the script sends and state the expected formats in one place:
# pip install python-dotenv
import imaplib
import os
from dotenv import dotenv_values
def login_checked(env_file: str = ".env") -> imaplib.IMAP4_SSL:
values = dotenv_values(env_file) # changed: read raw values, no shell expansion
host = (values.get("MAIL_HOST") or "").strip()
user = (values.get("MAIL_USER") or "").strip()
password = values.get("MAIL_PASSWORD") or ""
if "@" not in user:
raise ValueError("MAIL_USER must be the full email address for most providers")
if password != password.strip():
raise ValueError("MAIL_PASSWORD has leading or trailing whitespace")
conn = imaplib.IMAP4_SSL(host, 993, timeout=30)
try:
conn.login(user, password) # imaplib quotes special characters itself
except imaplib.IMAP4.error as exc:
conn.logout()
raise RuntimeError(f"login rejected by {host}: {exc}") from exc
return conn
Passwords containing $ are a frequent trap when set through a shell (export MAIL_PASSWORD=pa$$word expands $$ to a process id) or through tools that interpolate variables in .env files. Wrap such values in single quotes in the .env file and read them with dotenv_values. Non-ASCII passwords can also fail with imaplib's LOGIN command, which sends ASCII; AUTHENTICATE PLAIN with UTF-8 encoding works where the server supports it.
Keeping Authentication Working Over Time
Authentication failures rarely happen on day one; they happen months later when a token expires, a password rotates, or a tenant policy changes. Build the job to fail loudly and specifically, and to refresh what can be refreshed automatically:
# pip install msal
import logging
import time
import msal
log = logging.getLogger("mail-auth")
class TokenProvider:
"""Caches an app-only token and refreshes it a few minutes before expiry."""
def __init__(self, app: msal.ConfidentialClientApplication, scope: str):
self.app, self.scope = app, scope
self._token, self._expires = None, 0.0
def get(self) -> str:
if self._token is None or time.time() > self._expires - 300:
result = self.app.acquire_token_for_client(scopes=[self.scope])
if "access_token" not in result:
log.error("token refresh failed: %s", result.get("error_description"))
raise RuntimeError("cannot obtain access token")
self._token = result["access_token"]
self._expires = time.time() + int(result.get("expires_in", 3600))
return self._token
For app-password setups, alert on the first authentication failure rather than retrying: repeated failed logins can lock the account or trigger security alerts, turning a rotated password into a locked mailbox. Put the credential's owner and rotation date in the job's documentation so whoever gets the alert knows who can issue a new one. The alerting side is covered in add retries and failure alerts to automation jobs.
Verification
Confirm authentication in the environment the job actually runs in — the scheduler, container or service account — not on your laptop, and confirm the session can do what the job needs, not merely log in.
# stdlib only
import imaplib
def verify_session(conn: imaplib.IMAP4_SSL, folders=("INBOX", "Processed", "Ignored")) -> None:
typ, data = conn.list()
assert typ == "OK", "LIST failed after login"
names = {line.decode(errors="replace").split(' "/" ')[-1].strip('"') for line in data if line}
missing = [f for f in folders if f not in names]
assert not missing, f"folders missing (create them first): {missing}"
typ, _ = conn.select("INBOX", readonly=True)
assert typ == "OK", "cannot open INBOX"
typ, data = conn.uid("SEARCH", None, "ALL")
assert typ == "OK", "search not permitted"
print(f"session verified: {len(data[0].split())} message(s) visible in INBOX")
conn.logout()
Run the check as the first step of every scheduled execution. It turns "the job found no attachments" — which is indistinguishable from a quiet day — into an explicit failure when access is broken.
FAQ
Can I turn basic authentication back on for Microsoft 365? No. Microsoft has permanently disabled it for IMAP in Exchange Online. OAuth or the Graph API are the supported routes.
Do app passwords work with Google Workspace accounts? Only if the administrator allows them and the user has 2-step verification enabled. Workspace administrators can disable them organisation-wide, in which case OAuth is required.
Why does login work from my machine but not from the server?
Conditional access or IP-based policies may block the server's network, or the server reads a different .env. Run the diagnostic on the server itself.
Is it safe to log the server's error message? Yes — the error text does not contain the password. Never log the password, the token or the XOAUTH2 string, which contains the bearer token.
Related
- Processing Email Attachments Automatically — the intake job this login feeds
- Download Attachments from Microsoft 365 with the Graph API — the non-IMAP route for Exchange Online
- Email Generated Reports with Python — the sending side, with the same authentication changes for SMTP
- Fix Cron Job Not Running Python Script — environment differences that hide credentials from scheduled jobs