msal
MSAL for Python is Microsoft's official client library for getting OAuth2 access tokens out of Microsoft Entra ID (what used to be called Azure AD), personal Microsoft accounts, and Azure AD B2C. You build one of two objects: PublicClientApplication for code that cannot keep a secret (desktop apps, CLIs) or ConfidentialClientApplication for servers and daemons holding a client secret or certificate. Every flow then has the same shape: ask the token cache first with acquire_token_silent, and only when that returns nothing, call one of the acquire_token_by_* methods that actually hits the identity provider. MSAL handles refresh tokens, cache lookups, and authority discovery, then hands you a plain dict containing either access_token or error.
If your identity provider is Entra ID and you need real control over the flow or the cache, MSAL is the only supported answer and it is carefully built. Try azure-identity first though: most Python code that installs MSAL only ever needed a credential object.
Use it if
- You sign users into Entra ID or Microsoft accounts and need tokens for Microsoft Graph or your own Entra-protected API
- You run a daemon or background service that needs app-only tokens through client credentials, with either a secret or a certificate
- You need a flow azure-identity does not expose directly: device code with your own UI, on-behalf-of token exchange, or a separate token cache per signed-in web session
- You build a multi-tenant SaaS app and need to point the same code at different tenant authorities at runtime
- You are inside Azure and only need a credential object to hand to Azure SDK clients: azure-identity wraps MSAL, gives you DefaultAzureCredential, and picks managed identity, CLI login, or env vars for you with far less code
- Your identity provider is not Microsoft: MSAL is hard-wired to Entra endpoints, authority formats, and AADSTS error codes; Authlib is the provider-agnostic OAuth2 and OIDC client
- Your app is async: MSAL Python is synchronous on top of requests, so every token call blocks the event loop in FastAPI or aiohttp unless you push it to a thread pool yourself
- You expect to solve problems from the docs: the README is a stub that redirects to a GitHub wiki, a readthedocs API reference, and Microsoft Learn, and the errors you will actually hit are AADSTS codes explained in none of them
- You want a small dependency: it pulls in requests, PyJWT with the crypto extra, and cryptography, which means a compiled dependency in your image
Setup reality
pip install msal is instant, but pip is not the work. The work is in the Entra portal: register an app, choose the supported account types correctly the first time, add redirect URIs that match byte-for-byte what your code sends, and grant admin consent for application permissions or every daemon call dies with an AADSTS error. Scopes are picky in ways that surprise people: MSAL raises ValueError if you pass openid, profile, or offline_access because it adds those itself, and client-credentials calls need the resource/.default form rather than individual permission names. The token cache is in-memory only unless you wire up SerializableTokenCache or msal-extensions, so every process restart re-prompts every user. There is no async API, and Windows or macOS broker support needs the separate broker extra.
Patterns
Get an app-only token for a background servicedaemon-client-credentials
import msal
app = msal.ConfidentialClientApplication(
CLIENT_ID,
authority=f"https://login.microsoftonline.com/{TENANT_ID}",
client_credential=CLIENT_SECRET,
)
result = app.acquire_token_for_client(
scopes=["https://graph.microsoft.com/.default"]
)
if "access_token" in result:
token = result["access_token"]
else:
raise RuntimeError(result.get("error_description"))Client-credentials scopes must be the resource plus /.default. Passing individual permission names like User.Read.All returns an AADSTS error, because app permissions are granted by admin consent, not requested per call.
Always check the cache before calling the networksilent-first-token
result = None
accounts = app.get_accounts(username=known_username)
if accounts:
result = app.acquire_token_silent(SCOPES, account=accounts[0])
if not result:
result = app.acquire_token_interactive(scopes=SCOPES)acquire_token_silent returns None when there is nothing usable in the cache, so test for falsiness, not for an exception. Use acquire_token_silent_with_error instead when you need to see why the silent path failed.
Keep the token cache across restartspersist-token-cache
import os, atexit, msal
cache = msal.SerializableTokenCache()
if os.path.exists("token_cache.bin"):
cache.deserialize(open("token_cache.bin", "r").read())
atexit.register(lambda: (
open("token_cache.bin", "w").write(cache.serialize())
if cache.has_state_changed else None
))
app = msal.PublicClientApplication(CLIENT_ID, authority=AUTHORITY, token_cache=cache)The default cache is in-memory, so without this every restart re-prompts the user. A plain file holds refresh tokens in cleartext; use msal-extensions for OS keyring encryption and cross-process locking.
Sign in on a machine with no browserdevice-code-flow
flow = app.initiate_device_flow(scopes=["User.Read"])
if "user_code" not in flow:
raise RuntimeError(f"Device flow failed: {flow}")
print(flow["message"]) # tells the user which URL and code to use
result = app.acquire_token_by_device_flow(flow) # blocks until they finishacquire_token_by_device_flow polls and blocks until the user completes sign-in or the flow expires. Pass the whole flow dict back in, not just the code, and never print it from a thread you need responsive.
Authorization code flow in a web appweb-app-auth-code-flow
# step 1: on the login route
flow = app.initiate_auth_code_flow(
scopes=["User.Read"], redirect_uri="https://example.com/auth/callback"
)
session["flow"] = flow
return redirect(flow["auth_uri"])
# step 2: on the callback route
result = app.acquire_token_by_auth_code_flow(session.pop("flow"), request.args)Store the flow dict in the user session: it carries the state and PKCE verifier MSAL checks on return. Prefer this pair over the older acquire_token_by_authorization_code, which leaves state validation to you.
Authenticate with a certificate instead of a secretcertificate-credential
app = msal.ConfidentialClientApplication(
CLIENT_ID,
authority=AUTHORITY,
client_credential={
"private_key": open("key.pem").read(),
"thumbprint": "A1B2C3D4E5F6...",
# optional, enables subject name / issuer auth:
"public_certificate": open("cert.pem").read(),
},
)The thumbprint is the SHA-1 hex of the certificate and must match what Entra shows for the uploaded cert. Certificates avoid the secret-rotation treadmill, and many tenants now block secrets outright by policy.
Do not pass OIDC scopes yourselfreserved-scopes-error
# raises ValueError: API does not accept 'openid' value as user-provided scopes
app.acquire_token_interactive(scopes=["openid", "profile", "User.Read"])
# correct: MSAL adds openid, profile, and offline_access for you
result = app.acquire_token_interactive(scopes=["User.Read"])This trips up everyone porting a raw OAuth2 request into MSAL. Ask only for resource scopes; the ID token and refresh token come along automatically.
Handle the failure dict properlyread-error-details
result = app.acquire_token_for_client(scopes=SCOPES)
if "access_token" not in result:
log.error(
"token request failed: %s | %s | correlation_id=%s",
result.get("error"),
result.get("error_description"),
result.get("correlation_id"),
)MSAL returns errors as dict entries rather than raising, so a missing check silently produces a KeyError later. The AADSTS code inside error_description is the only searchable part; log the correlation_id for Microsoft support.
Exchange an incoming user token for a downstream tokenon-behalf-of-flow
# your API received `incoming_jwt` from a client app
result = app.acquire_token_on_behalf_of(
user_assertion=incoming_jwt,
scopes=["https://graph.microsoft.com/Files.Read"],
)This is the middle-tier pattern: your API calls Graph as the caller, not as itself. It needs a ConfidentialClientApplication and a pre-authorized client, and it fails with AADSTS65001 until consent is granted for the downstream scope.
Use an Azure managed identitymanaged-identity-token
import requests, msal
client = msal.ManagedIdentityClient(
msal.SystemAssignedManagedIdentity(),
http_client=requests.Session(),
)
result = client.acquire_token_for_client(resource="https://vault.azure.net")ManagedIdentityClient takes a resource, not a scopes list, unlike every other MSAL call. Use UserAssignedManagedIdentity(client_id=...) when the host has more than one identity assigned.
Get the signed-in user out of the resultread-id-token-claims
claims = result.get("id_token_claims", {})
user_id = claims.get("oid") # stable per user per tenant
tenant_id = claims.get("tid")
email = claims.get("preferred_username")Key your own user records on oid plus tid, not on preferred_username or email: those are mutable and can be reassigned to another person after an account is deleted.
Build the application object oncereuse-application-object
# module scope, not per request
_app = msal.ConfidentialClientApplication(
CLIENT_ID, authority=AUTHORITY, client_credential=CLIENT_SECRET
)
def get_token():
return _app.acquire_token_for_client(scopes=SCOPES)Constructing the app per request throws away the token cache, so every call goes to the network and you will eventually get throttled by Entra. The instance is safe to share across threads.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| azure-identity | PyPI | You only need a credential to pass into Azure SDK clients and want DefaultAzureCredential to choose the right token source per environment |
| authlib | PyPI | Your OAuth2 or OIDC provider is not Microsoft, or one client has to speak to several providers |
| msal-extensions | PyPI | You already use MSAL and need the token cache persisted to disk with OS-level encryption and cross-process locking |