mrkeyoor.com_
Thu 06 Aug 01:00 UTC
PyPISecurityupdated 05 Aug 2026

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.

Verdict

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.

API stability5/5The PublicClientApplication and ConfidentialClientApplication shape has held since 1.0.0 in November 2019; new capabilities arrive as new acquire_token_* methods rather than as breaking changes to old ones.
Docs3/5The README mostly links elsewhere, real content is split across a GitHub wiki, a readthedocs reference, and Microsoft Learn, and the runnable samples in the repo end up being the most useful documentation.
Maintenance4/5Microsoft owns it and it shows: 1.37.0 shipped May 2026, the repo was pushed August 2026, and 65 issues are open (79 counting PRs), though triage is on Microsoft's schedule, not yours.
Ecosystem4/5Around 52M weekly downloads, most of it transitive through azure-identity, plus first-party samples for every scenario; the trade-off is that everything around it is Microsoft's and there is little third-party tooling.

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
Skip it if

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 finish

acquire_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

PackageRegistryPick it when
azure-identityPyPIYou only need a credential to pass into Azure SDK clients and want DefaultAzureCredential to choose the right token source per environment
authlibPyPIYour OAuth2 or OIDC provider is not Microsoft, or one client has to speak to several providers
msal-extensionsPyPIYou already use MSAL and need the token cache persisted to disk with OS-level encryption and cross-process locking