OAuth Social Login & LDAP
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Flask 3.1.3 (released 2026-02-18), Werkzeug 3.1.9, Authlib 1.8.0 (2026-08-30),
Flask-Dance 7.1.0 (2024-03-05), ldap3 2.9.1 (2021-07-18), python-ldap 3.4.8 (2026-09-16) and Python 3.13, checked on
2026-10-07. Every example was executed with those versions against local fakes: a stand-in OpenID Connect provider,
canned GitHub responses and the MOCK_SYNC strategy of ldap3, so no account, network or directory server is needed.
Flask 3.2 is not released yet and its unreleased changelog contains nothing that changes the features used here.
Most applications stop wanting to store passwords sooner or later. Either visitors prefer "Sign in with Google" or "Sign in with GitHub", or the company already runs a directory (an LDAP server such as OpenLDAP or Active Directory) that holds every employee’s credentials. In both cases the application delegates the question "who is this?" to a system that already knows, and receives an answer it must verify before it creates its own session. The page builds that for the running Bookshelf application: social login with Authlib (the maintained, protocol-complete choice), the older Flask-Dance extension that the Flask cookbooks teach, and LDAP with a search-then-bind flow. The protocol theory is not repeated here; it lives in the OAuth section of the site, starting with Social Login and Federation, Authorization Code and PKCE and OpenID Connect.
Delegated Sign-In at a Glance
Whatever the mechanism, the shape is the same: Flask redirects the visitor (or the visitor’s credentials) to an authority, gets back proof of identity, maps it to a local user and then does what every other login does, which is to start a fresh session (see Authentication & Flask-Login). The mechanisms differ in who sees the password and in what proof comes back.
| Mechanism | Who sees the user’s password | What Flask receives | Typical use |
|---|---|---|---|
OpenID Connect login (Google, Microsoft, Keycloak, Auth0 and others) |
Only the provider |
A signed ID token with |
Public sign-up, single sign-on across your own applications |
OAuth 2.0 only login (GitHub is the common case) |
Only the provider |
An access token; the identity is read from an API call |
Developer-facing products |
Flask-Dance blueprint |
Only the provider |
An access token held in the session or a database; no ID token validation |
Quick "Sign in with X" for providers that Flask-Dance ships, and any plain OAuth 2.0 server |
LDAP simple bind |
Your application (it forwards the password to the directory) |
A yes or no, plus attributes you search for |
Intranet tools where users type their company password into your form |
The last row is the important difference. With OAuth and OpenID Connect the user’s password never reaches Flask. With LDAP it does, so the page spends as much time on TLS, escaping and not logging the password as on the protocol. The figure shows the browser-based flow that Authlib automates; each step with a name in it is something the library does for you and that a hand-written implementation tends to forget.
Choosing a Library
Three libraries are involved, and they differ in how much of the protocol they own.
| Library | What it does | What it leaves to you |
|---|---|---|
Authlib ( |
Provider registry configured from |
Mapping claims to users, account linking, session handling, error pages |
Flask-Dance (built on requests-oauthlib and oauthlib) |
One blueprint per provider with login and callback routes already wired; ready-made blueprints for more than twenty providers (GitHub, Google, Facebook, Slack, Azure and others); token storage in the session or in SQLAlchemy; signals |
Everything about OpenID Connect: it never reads or validates an ID token and sends no nonce; |
ldap3 and python-ldap |
ldap3 is pure Python with a built-in fake server for tests; python-ldap wraps the OpenLDAP C library |
Escaping, TLS settings, timeouts, search-then-bind, group mapping, provisioning |
Two maintenance facts shape the choice. Flask-Dance’s latest release is 7.1.0 from March 2024, and ldap3’s latest release is 2.9.1 from July 2021; both still work with Python 3.13 and the examples below ran, but neither ships fixes quickly, so read their issue trackers before you depend on them. Authlib and python-ldap are released regularly. This page therefore teaches Authlib first, shows Flask-Dance because the books and many existing code bases use it, and uses ldap3 for the runnable LDAP examples while showing the python-ldap equivalents of the safety-critical calls.
For production OpenID Connect, prefer a library that validates the ID token over a library that only moves OAuth
tokens around. Flask-Dance’s Google blueprint, for example, defaults to the userinfo.profile scope, which has no openid in it, so
there is no ID token at all: the application calls an API with the access token. That is OAuth, not OpenID Connect,
and it gives no proof that the response belongs to this login attempt in the way a validated, nonce-bound ID token
does.
Setup and the Bookshelf Identity Model
Install the libraries into the project’s environment. responses is only used by the tests on this page, to keep
the provider fakes inside the process.
pip install "Flask>=3.1" "Authlib>=1.8" Flask-Dance ldap3 python-ldap responses
Source: Authlib on PyPI
The first decision is not about OAuth at all: how does a provider’s answer become a local user? The robust rule is
to key the link on the pair (iss, sub), the issuer and the subject, because the subject is a stable identifier
the provider promises never to reuse for someone else, while an e-mail address can change hands. The e-mail is
profile data, and it may be used for linking only when the provider says it is verified. The Bookshelf therefore has
a users table and an identities table, and a small Profile value that every provider adapter produces.
import os
import secrets
import sqlite3
from dataclasses import dataclass
from urllib.parse import urlsplit
from authlib.integrations.flask_client import OAuth, OAuthError
from flask import Flask, abort, current_app, jsonify, redirect, request, session, url_for
from joserfc.errors import JoseError
@dataclass(frozen=True)
class Profile:
issuer: str # who vouches for the identity
subject: str # the provider's stable id for the person; never reused
email: str | None
email_verified: bool
name: str | None
SCHEMA = """
CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE, display_name TEXT);
CREATE TABLE identities (
issuer TEXT NOT NULL,
subject TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users (id),
PRIMARY KEY (issuer, subject)
);
"""
class AccountLinkRequired(Exception):
"""The e-mail belongs to an existing account that has not linked this identity."""
def find_or_create_user(db, profile):
row = db.execute(
"SELECT user_id FROM identities WHERE issuer = ? AND subject = ?",
(profile.issuer, profile.subject),
).fetchone()
if row:
return row[0]
if db.execute("SELECT 1 FROM users WHERE email = ?", (profile.email,)).fetchone():
raise AccountLinkRequired # never merge accounts on an e-mail match alone
user_id = db.execute(
"INSERT INTO users (email, display_name) VALUES (?, ?)", (profile.email, profile.name)
).lastrowid
db.execute("INSERT INTO identities VALUES (?, ?, ?)", (profile.issuer, profile.subject, user_id))
db.commit()
return user_id
def safe_next(target):
"""Return target only if it is a same-site path such as /books/7."""
if not target or not target.startswith("/") or target.startswith("//") or "\\" in target:
return None
parts = urlsplit(target)
return target if not parts.scheme and not parts.netloc else None
Source: Authlib: Flask Integration
Two details of that block carry security weight. find_or_create_user refuses to attach a new identity to an
existing account just because the e-mail matches: an attacker who controls a provider account with the victim’s
address would otherwise take over the victim’s Bookshelf account. The correct flow is "sign in with the old method
first, then link", as discussed in Social Login and Federation.
And safe_next is there because a login route that stores a next address and redirects to it afterwards is the
classic open redirect; only a path on the same site is accepted.
Sign In with Authlib
Authlib’s Flask client is a registry. You create an OAuth object, register each provider under a short name, and the
registry builds one client per name. The configuration can live in app.config: for a client registered as idp,
the keys IDP_CLIENT_ID, IDP_CLIENT_SECRET and IDP_SERVER_METADATA_URL are read automatically (the name in
upper case, an underscore and the parameter in upper case), so credentials never appear in the code.
app.config.from_prefixed_env() maps environment variables such as FLASK_IDP_CLIENT_ID into those keys, which is
how a container or a secret store feeds them in; see Configuration.
register() parameter or config key |
Meaning |
|---|---|
|
The credentials issued by the provider when you registered the application. Keep them in the environment or a secret store, never in the repository. |
|
URL of the provider’s OpenID Connect discovery document ( |
|
The manual alternative for providers without discovery, such as GitHub. |
|
Extra settings for the underlying OAuth 2 session: |
|
Extra query or body parameters for those endpoints, for example |
|
Where the per-login state is kept. By default it is the Flask session cookie; a cache object with |
When the registered scope contains openid, Authlib also creates a nonce, includes it in the authorization request
and, on the way back, validates the ID token (signature against the provider’s JWKS, iss, aud, exp, iat and
the nonce) and places the claims in token["userinfo"]. The state, the nonce and the PKCE verifier are saved in the
Flask session under a key derived from the state value and removed on the first callback, which is also why a replayed
callback fails. Three things in the Bookshelf factory below are deliberate: the session.clear() once the user is
known (a fresh session contents after authentication, the defence against session fixation covered in
Sessions, Cookies & Flashing), SESSION_COOKIE_SAMESITE="Lax"
(explained below), and one generic error response for every failure, so that provider details do not leak and the
page cannot be used as an oracle.
def profile_from_oidc(client, token):
claims = token["userinfo"] # the validated ID token claims
return Profile(claims["iss"], claims["sub"], claims.get("email"),
claims.get("email_verified") is True, claims.get("name"))
def profile_from_github(client, token):
user = client.get("user") # Authlib sends the access token
user.raise_for_status()
emails = client.get("user/emails")
emails.raise_for_status()
primary = next((e for e in emails.json() if e["primary"] and e["verified"]), None)
return Profile("https://github.com", str(user.json()["id"]),
primary["email"] if primary else None, primary is not None,
user.json().get("name") or user.json()["login"])
LOADERS = {"idp": profile_from_oidc, "github": profile_from_github}
def create_app(test_config=None, cache=None):
app = Flask(__name__)
app.config.from_mapping(
SECRET_KEY=os.environ.get("SECRET_KEY") or secrets.token_hex(32), # a dev fallback only
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
)
app.config.from_prefixed_env() # FLASK_IDP_CLIENT_ID, FLASK_GITHUB_CLIENT_SECRET, ...
if test_config:
app.config.update(test_config)
app.extensions["db"] = db = sqlite3.connect(":memory:", check_same_thread=False)
db.executescript(SCHEMA)
oauth = OAuth(app, cache=cache) # reads <NAME>_CLIENT_ID, <NAME>_CLIENT_SECRET, ... from app.config
oauth.register(
"idp", # any OpenID Connect provider with a discovery document
client_kwargs={"scope": "openid email profile", "code_challenge_method": "S256"},
)
oauth.register( # GitHub speaks OAuth 2.0 only: no ID token, no discovery
"github",
authorize_url="https://github.com/login/oauth/authorize",
access_token_url="https://github.com/login/oauth/access_token",
api_base_url="https://api.github.com/",
client_kwargs={
"scope": "read:user user:email",
"code_challenge_method": "S256",
"token_endpoint_auth_method": "client_secret_post",
},
)
@app.get("/login/<provider>")
def login(provider):
client = oauth.create_client(provider) if provider in LOADERS else None
if client is None:
abort(404)
session["next"] = safe_next(request.args.get("next"))
return client.authorize_redirect(url_for("auth_callback", provider=provider, _external=True))
@app.get("/auth/<provider>/callback")
def auth_callback(provider):
client = oauth.create_client(provider) if provider in LOADERS else None
if client is None:
abort(404)
try:
token = client.authorize_access_token() # checks state, uses the PKCE verifier
profile = LOADERS[provider](client, token) # for OIDC the ID token is validated above
except (OAuthError, JoseError) as exc:
current_app.logger.warning("%s login failed: %s", provider, getattr(exc, "error", type(exc).__name__))
return jsonify(error="login_failed"), 400
if not profile.email_verified:
return jsonify(error="email_not_verified"), 403
try:
user_id = find_or_create_user(db, profile)
except AccountLinkRequired:
return jsonify(error="sign_in_to_link"), 409
target = session.pop("next", None)
id_token = token.get("id_token")
session.clear() # fresh session after authentication
session["user_id"] = user_id
if id_token:
session["id_token"] = id_token # only needed for RP-initiated logout
return redirect(target or url_for("me"))
@app.get("/me")
def me():
if "user_id" not in session:
abort(401)
row = db.execute("SELECT id, email, display_name FROM users WHERE id = ?",
(session["user_id"],)).fetchone()
return jsonify(id=row[0], email=row[1], name=row[2])
@app.post("/logout")
def logout():
session.clear()
return "", 204
@app.post("/logout/idp")
def logout_idp():
hint = session.pop("id_token", None)
session.clear()
return oauth.idp.logout_redirect(url_for("signed_out", _external=True), id_token_hint=hint)
@app.get("/signed-out")
def signed_out():
oauth.idp.validate_logout_response() # checks the state of the logout round trip
return jsonify(status="signed out")
return app
Source: Authlib: Flask Integration
Why SESSION_COOKIE_SAMESITE="Lax" and not "Strict"? The callback is a cross-site top-level navigation: the
provider redirects the browser back to /auth/idp/callback. A Lax cookie is sent on such a navigation; a Strict
cookie is not, so the callback would arrive without the session that holds the state, and the login would fail with a
state mismatch that looks like a bug in the library. Keep Lax for the session cookie of an application that offers
social login (see Security Considerations for the other cookie
options). The ID token kept for logout makes the signed cookie larger (the demo cookie is about 800 bytes and real tokens
with more claims are bigger), which is acceptable only while it stays far below the 4 KB limit; with
a server-side session store (Flask-Session) the size does not matter, and an application that never uses RP-initiated
logout can simply not store it.
A Local Stand-In for the Provider
Testing a login flow against the real Google or GitHub is slow, needs credentials and breaks in CI. The usual answer
is a fake provider that speaks the same HTTP: the application stays unchanged, and only the network is replaced.
Authlib’s sessions are built on requests, so the responses library can route every call to the provider’s host
into a small Flask application that implements the four endpoints a code flow needs (discovery, JWKS, token and
userinfo) and signs ID tokens with a freshly generated RSA key. The same class is reused for the Flask-Dance section.
It also enforces PKCE, so a client that forgets the verifier fails here exactly as it would in production. Nothing in
it is a real secret: the client id, the client secret and the signing key are generated when the object is created.
import base64
import hashlib
import re
import time
from contextlib import contextmanager
from urllib.parse import parse_qs
import responses
from flask import request
from joserfc import jwt
from joserfc.jwk import RSAKey
class FakeIdP:
"""A local stand-in for an OpenID Connect provider (never talks to the network)."""
issuer = "https://idp.example.test"
def __init__(self):
self.client_id = "bookshelf-" + secrets.token_hex(4)
self.client_secret = secrets.token_hex(16)
self.key = RSAKey.generate_key(2048, parameters={"kid": "k1", "use": "sig"})
self.codes = {}
self.tokens = {}
self.claims = {"sub": "1001", "email": "ada@example.test", "email_verified": True, "name": "Ada"}
self.app = self._build_app()
def config(self):
return {
"IDP_CLIENT_ID": self.client_id,
"IDP_CLIENT_SECRET": self.client_secret,
"IDP_SERVER_METADATA_URL": f"{self.issuer}/.well-known/openid-configuration",
}
def _build_app(self):
app = Flask("fake-idp")
@app.get("/.well-known/openid-configuration")
def metadata():
return jsonify(
issuer=self.issuer,
authorization_endpoint=f"{self.issuer}/authorize",
token_endpoint=f"{self.issuer}/token",
jwks_uri=f"{self.issuer}/jwks",
userinfo_endpoint=f"{self.issuer}/userinfo",
end_session_endpoint=f"{self.issuer}/logout",
response_types_supported=["code"],
subject_types_supported=["public"],
id_token_signing_alg_values_supported=["RS256"],
code_challenge_methods_supported=["S256"],
token_endpoint_auth_methods_supported=["client_secret_basic", "client_secret_post"],
)
@app.get("/jwks")
def jwks():
return jsonify(keys=[self.key.as_dict(private=False)])
@app.post("/token")
def token():
auth = request.authorization
cid = auth.username if auth else request.form.get("client_id")
secret = auth.password if auth else request.form.get("client_secret")
if cid != self.client_id or not secrets.compare_digest(secret or "", self.client_secret):
return jsonify(error="invalid_client"), 401
record = self.codes.pop(request.form.get("code"), None) # a code works once
if record is None or record["redirect_uri"] != request.form.get("redirect_uri"):
return jsonify(error="invalid_grant"), 400
digest = hashlib.sha256(request.form.get("code_verifier", "").encode()).digest()
if base64.urlsafe_b64encode(digest).rstrip(b"=").decode() != record["challenge"]:
return jsonify(error="invalid_grant", error_description="PKCE verification failed"), 400
now = int(time.time())
claims = {"iss": self.issuer, "aud": self.client_id, "iat": now, "exp": now + 300,
"nonce": record["nonce"], **self.claims, **record["overrides"]}
id_token = jwt.encode({"alg": "RS256", "kid": "k1"}, claims, self.key)
access_token = secrets.token_hex(16)
self.tokens[access_token] = {"iss": self.issuer, **self.claims}
return jsonify(access_token=access_token, token_type="Bearer", expires_in=300, id_token=id_token)
@app.get("/userinfo")
def userinfo():
claims = self.tokens.get(request.headers.get("Authorization", "").removeprefix("Bearer "))
return (jsonify(claims), 200) if claims else (jsonify(error="invalid_token"), 401)
@app.get("/logout")
def end_session():
return redirect(request.args["post_logout_redirect_uri"] + "?state=" + request.args["state"])
return app
def approve(self, authorization_url, **overrides):
"""What the browser does after the user clicks Allow: return the callback URL."""
query = {k: v[0] for k, v in parse_qs(urlsplit(authorization_url).query).items()}
assert query["client_id"] == self.client_id and query["response_type"] == "code"
code = secrets.token_hex(8)
self.codes[code] = {"nonce": query.get("nonce"), "challenge": query["code_challenge"],
"redirect_uri": query["redirect_uri"], "overrides": overrides}
return f"{query['redirect_uri']}?code={code}&state={query['state']}"
def install(self, rsps):
"""Route every requests call to the provider host into this fake."""
pattern = re.compile(re.escape(self.issuer) + "/.*")
for method in (responses.GET, responses.POST):
rsps.add_callback(method, pattern, callback=self._forward)
def _forward(self, req):
path = req.url[len(self.issuer):]
resp = self.app.test_client().open(path, method=req.method, data=req.body, headers=dict(req.headers))
return resp.status_code, dict(resp.headers), resp.get_data()
@contextmanager
def provider_online(idp):
with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
idp.install(rsps)
yield rsps
Source: Authlib: Flask Integration
Running the Flow and Its Failure Modes
A test drives the application like a browser: ask for /login/idp, follow the redirect to the provider (here,
approve() plays the user clicking Allow) and then call the callback with the code and state the provider returned.
The test client keeps the session cookie between the calls, and base_url makes the requests arrive over HTTPS so the
Secure cookie is honoured.
BASE = "https://books.example.test"
def browser_login(client, idp, provider="idp", next_url=None, **overrides):
"""Drive /login -> provider -> /auth/<provider>/callback like a browser would."""
query = f"?next={next_url}" if next_url else ""
resp = client.get(f"/login/{provider}{query}", base_url=BASE)
callback = idp.approve(resp.headers["Location"], **overrides)
return client.get(callback.removeprefix(BASE), base_url=BASE)
idp = FakeIdP()
app = create_app(idp.config())
with provider_online(idp):
client = app.test_client()
first = client.get("/login/idp", base_url=BASE)
params = parse_qs(urlsplit(first.headers["Location"]).query)
print(sorted(params))
print(params["scope"], params["code_challenge_method"])
callback = idp.approve(first.headers["Location"])
resp = client.get(callback.removeprefix(BASE), base_url=BASE)
print(resp.status_code, resp.headers["Location"])
print(client.get("/me", base_url=BASE).get_json())
Source: Authlib: Flask Integration
The first line shows what Authlib put into the authorization request without being asked (state, nonce and the
PKCE code_challenge), and the last two lines show the finished login:
['client_id', 'code_challenge', 'code_challenge_method', 'nonce', 'redirect_uri', 'response_type', 'scope', 'state']
['openid email profile'] ['S256']
302 /me
{'email': 'ada@example.test', 'id': 1, 'name': 'Ada'}
An easy way to be sure that the protections work is to attack the callback. Each case below starts from a fresh
browser and changes one thing; every one must end in a generic login_failed or in a refusal, and none may create a
session.
BASE_CLAIMS = dict(idp.claims)
def attempt(label, claims=None, **overrides):
idp.claims = {**BASE_CLAIMS, **(claims or {})}
resp = browser_login(app.test_client(), idp, **overrides)
print(f"{label:<28}", resp.status_code, resp.get_json() or resp.headers.get("Location"))
with provider_online(idp):
attempt("e-mail not verified", {"sub": "3003", "email": "bob@example.test", "email_verified": False})
attempt("same e-mail, new subject", {"sub": "2002"})
attempt("wrong nonce in ID token", nonce="not-the-one-we-sent")
attempt("open redirect in next", next_url="//evil.example/x")
browser = app.test_client() # forged state
start = browser.get("/login/idp", base_url=BASE)
callback = idp.approve(start.headers["Location"]).replace("state=", "state=x")
resp = browser.get(callback.removeprefix(BASE), base_url=BASE)
print(f"{'forged state':<28}", resp.status_code, resp.get_json())
start = browser.get("/login/idp", base_url=BASE) # callback without the session cookie
callback = idp.approve(start.headers["Location"])
resp = app.test_client().get(callback.removeprefix(BASE), base_url=BASE)
print(f"{'session cookie not sent':<28}", resp.status_code, resp.get_json())
resp = app.test_client().get("/auth/idp/callback?error=access_denied", base_url=BASE)
print(f"{'user denied consent':<28}", resp.status_code, resp.get_json())
start = browser.get("/login/idp", base_url=BASE) # the same callback twice
callback = idp.approve(start.headers["Location"]).removeprefix(BASE)
print(f"{'first use':<28}", browser.get(callback, base_url=BASE).status_code)
print(f"{'replayed callback':<28}", browser.get(callback, base_url=BASE).status_code)
Source: Authlib: Flask Integration
e-mail not verified 403 {'error': 'email_not_verified'}
same e-mail, new subject 409 {'error': 'sign_in_to_link'}
wrong nonce in ID token 400 {'error': 'login_failed'}
open redirect in next 302 /me
forged state 400 {'error': 'login_failed'}
session cookie not sent 400 {'error': 'login_failed'}
user denied consent 400 {'error': 'login_failed'}
first use 302
replayed callback 400
The two cases that need a human decision are the ones that the library cannot make for you. An unverified e-mail must
not be used to create or link an account. A known e-mail with a new subject means that a person with a second
provider account (or an attacker) wants into an existing user; the answer is to ask them to sign in the old way
first and to link the identity from inside the authenticated session, never to merge silently. Everything else,
a forged state, a lost session cookie, a wrong nonce, a denied consent or a replay, is already refused by Authlib’s
checks, and the application only has to turn the exception into a calm error page. Note that invalid ID token claims
raise a joserfc JoseError, not an Authlib OAuthError, which is why the callback catches both.
Keeping the state out of the cookie
By default the per-login state (state, nonce, PKCE verifier and redirect URI) is stored inside the session cookie. For
an application with many concurrent logins or large cookies, OAuth(app, cache=...) stores it in a cache and leaves
only an expiry marker in the cookie. Any object with get, set and delete works; cachelib’s SimpleCache is
enough for a single process and its Redis or file-system siblings for several.
from cachelib import SimpleCache
cached_app = create_app(idp.config(), cache=SimpleCache())
with provider_online(idp):
browser = cached_app.test_client()
resp = browser_login(browser, idp)
print(resp.status_code, browser.get("/me", base_url=BASE).get_json()["email"])
OAuth 2.0 Without OpenID Connect: GitHub
GitHub’s "Sign in with GitHub" is plain OAuth 2.0: there is no ID token and no discovery document, so the identity has
to be read from the API with the access token. That is what profile_from_github does. Three points matter. The user
id (id, a number) is the stable key; the login handle can be renamed and reused. The profile’s email field can be
empty or can be a public address the user typed, so the verified, primary address comes from /user/emails, which
needs the user:email scope and returns primary and verified flags for each address. And PKCE is available:
GitHub’s documentation lists code_challenge and code_challenge_method (only S256) for the authorization request
and code_verifier for the token request as strongly recommended, so the registration above enables it. The token
endpoint is asked for client authentication in the request body (client_secret_post), the form the GitHub documentation
shows. Authlib is told only the URLs; the tests replace GitHub’s four endpoints by canned responses.
def fake_github(rsps, emails):
"""Register canned GitHub endpoints on a responses mock."""
rsps.post("https://github.com/login/oauth/access_token",
json={"access_token": secrets.token_hex(16), "token_type": "bearer",
"scope": "read:user,user:email"})
rsps.get("https://api.github.com/user", json={"id": 583231, "login": "ada-l", "name": "Ada L."})
rsps.get("https://api.github.com/user/emails", json=emails)
mail = [{"email": "ada@github.example", "primary": True, "verified": True, "visibility": "private"},
{"email": "old@github.example", "primary": False, "verified": False, "visibility": None}]
gh_app = create_app({"GITHUB_CLIENT_ID": "gh-" + secrets.token_hex(4),
"GITHUB_CLIENT_SECRET": secrets.token_hex(16)})
with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
fake_github(rsps, mail)
browser = gh_app.test_client()
start = browser.get("/login/github", base_url=BASE)
location = urlsplit(start.headers["Location"])
query = {k: v[0] for k, v in parse_qs(location.query).items()}
print(location.netloc + location.path, query["scope"], query["code_challenge_method"])
resp = browser.get("/auth/github/callback", query_string={"code": "fake-code", "state": query["state"]},
base_url=BASE)
print(resp.status_code, resp.headers["Location"], browser.get("/me", base_url=BASE).get_json())
print(sorted(parse_qs(rsps.calls[0].request.body)))
Source: GitHub: Authorizing OAuth apps
github.com/login/oauth/authorize read:user user:email S256
302 /me {'email': 'ada@github.example', 'id': 1, 'name': 'Ada L.'}
['client_id', 'client_secret', 'code', 'code_verifier', 'grant_type', 'redirect_uri']
The same pattern fits other OAuth 2.0 providers with an "identity" endpoint. When the provider does publish a discovery
document, use server_metadata_url and the OpenID Connect path instead: it validates a signed token rather than
trusting a JSON answer to a follow-up API call. For Google the discovery URL is
https://accounts.google.com/.well-known/openid-configuration, and the hd claim (the Google Workspace domain) can
be checked in profile_from_oidc when only company accounts may enter; Google’s documentation is explicit that the
hd request parameter is only a hint to the account chooser, so the claim in the validated token is what you test.
Flask-Dance
Flask-Dance packages the same flow as a blueprint. make_github_blueprint() returns a blueprint with two routes, the
login redirect (/github) and the authorization callback (/github/authorized); you register it with a
url_prefix, and Flask-Dance handles state, the token request and token storage. The client id and secret are read
from GITHUB_OAUTH_CLIENT_ID and GITHUB_OAUTH_CLIENT_SECRET in app.config (note the _OAUTH_ in the name, unlike
Authlib). The result is an access token that you use through the blueprint’s session or the github proxy. A login
application connects a receiver to the oauth_authorized signal, builds the profile there and, because it does not
need the token afterwards, returns False so that Flask-Dance does not store it.
from flask import make_response
from flask_dance.consumer import oauth_authorized, oauth_error
from flask_dance.contrib.github import make_github_blueprint
from oauthlib.oauth2 import OAuth2Error
def create_dance_app(test_config=None):
app = create_app(test_config) # the same factory, so /me and the database are shared
db = app.extensions["db"]
github_bp = make_github_blueprint(scope="read:user,user:email", redirect_to="me")
app.register_blueprint(github_bp, url_prefix="/dance") # /dance/github, /dance/github/authorized
@oauth_authorized.connect_via(github_bp)
def github_authorized(blueprint, token):
user = blueprint.session.get("/user") # base_url is https://api.github.com/
emails = blueprint.session.get("/user/emails")
if not (user.ok and emails.ok):
return False
primary = next((e for e in emails.json() if e["primary"] and e["verified"]), None)
if primary is None:
return False
profile = Profile("https://github.com", str(user.json()["id"]), primary["email"], True,
user.json().get("name") or user.json()["login"])
try:
user_id = find_or_create_user(db, profile)
except AccountLinkRequired:
return False
session.clear()
session["user_id"] = user_id
return False # login only: do not keep the GitHub token in the session
@oauth_error.connect_via(github_bp)
def github_error(blueprint, error, error_description=None, error_uri=None):
return make_response(jsonify(error="login_failed"), 400)
@app.errorhandler(OAuth2Error) # for example MismatchingStateError on a forged callback
def oauth_failure(error):
return jsonify(error="login_failed"), 400
return app
dance = create_dance_app({"GITHUB_OAUTH_CLIENT_ID": "gh-" + secrets.token_hex(4),
"GITHUB_OAUTH_CLIENT_SECRET": secrets.token_hex(16)})
with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps:
fake_github(rsps, mail)
browser = dance.test_client()
start = browser.get("/dance/github", base_url=BASE)
location = urlsplit(start.headers["Location"])
query = {k: v[0] for k, v in parse_qs(location.query).items()}
print(location.netloc + location.path, query["scope"], query["redirect_uri"])
resp = browser.get("/dance/github/authorized", query_string={"code": "fake-code", "state": query["state"]},
base_url=BASE)
print(resp.status_code, resp.headers["Location"], browser.get("/me", base_url=BASE).get_json())
denied = dance.test_client().get("/dance/github/authorized?error=access_denied", base_url=BASE)
print(denied.status_code, denied.get_json())
forged = dance.test_client()
forged.get("/dance/github", base_url=BASE)
resp = forged.get("/dance/github/authorized", query_string={"code": "fake-code", "state": "forged"},
base_url=BASE)
print(resp.status_code, resp.get_json())
Source: Flask-Dance: Signals
github.com/login/oauth/authorize read:user,user:email https://books.example.test/dance/github/authorized
302 /me {'email': 'ada@github.example', 'id': 1, 'name': 'Ada L.'}
400 {'error': 'login_failed'}
400 {'error': 'login_failed'}
Four details of Flask-Dance explain most of the support questions about it.
-
Scopes are a comma-separated string for GitHub. The documented signature takes a comma-separated list, and GitHub answers with a comma-separated
scopevalue. oauthlib compares the granted scope with the requested one and raises aWarning("Scope has changed") when they differ, so a Python list of two scopes fails against GitHub while the string"read:user,user:email"works. oauthlib skips that comparison when the environment variableOAUTHLIB_RELAX_TOKEN_SCOPEis set, which is the usual workaround for providers that spell the granted scope differently; prefer sending the scope in the form the provider returns it. Unhandled, the warning reaches the user as a 500 page. -
Errors are not handled for you. A forged
stateraises oauthlib’sMismatchingStateError, a subclass ofOAuth2Error, from the callback view; without the error handler above it is a 500. Provider-side errors such asaccess_deniedare sent through theoauth_errorsignal, and the user is redirected toredirect_urlanyway unless a receiver returns aResponse. -
Plain HTTP is refused. oauthlib raises
InsecureTransportErrorfor non-HTTPS URLs. Development servers that use HTTP setOAUTHLIB_INSECURE_TRANSPORT=1in the local environment only, and never in production, where the real fix is HTTPS (see the proxy section below). -
Token storage is a choice. The default
SessionStoragekeeps the token in the signed session cookie: readable by the user, about 4 KB, and gone when the cookie is.SQLAlchemyStorage(see Flask-Dance’s token storages page) keeps one token per user in your database, which is what a multi-user application that calls the provider’s API on behalf of its users needs; then the receiver also links the OAuth record to the user record. A login-only application should store nothing, as above.
The ready-made blueprints cover GitHub, Google, Facebook, Slack, Azure and many others, but there is no Twitter or
X module in Flask-Dance 7.1.0: the make_twitter_blueprint recipe that older books use depended on the
retired v1.1 API and its OAuth 1.0a dance, and X documents OAuth 2.0 with PKCE today. For a provider that Flask-Dance
does not ship, the generic OAuth2ConsumerBlueprint takes the endpoint URLs and, unlike the helper functions, accepts
use_pkce=True. Because Flask-Dance does not validate ID tokens, an OpenID Connect provider is used through its
userinfo endpoint: the blueprint’s session carries the access token, and the answer is trusted only because it comes
over TLS from the provider’s own host.
from flask_dance.consumer import OAuth2ConsumerBlueprint
other = FakeIdP() # a second fake provider with its own client and key
generic = Flask("generic")
generic.config["SECRET_KEY"] = secrets.token_hex(32)
bp = OAuth2ConsumerBlueprint(
"idp", __name__,
client_id=other.client_id, client_secret=other.client_secret,
base_url=other.issuer + "/", authorization_url=other.issuer + "/authorize",
token_url=other.issuer + "/token",
scope=["openid", "email", "profile"], use_pkce=True, redirect_to="done",
)
generic.register_blueprint(bp, url_prefix="/dance")
@generic.get("/done")
def done():
return jsonify(bp.session.get("userinfo").json())
with provider_online(other):
browser = generic.test_client()
start = browser.get("/dance/idp", base_url=BASE)
query = parse_qs(urlsplit(start.headers["Location"]).query)
print(sorted(query))
callback = other.approve(start.headers["Location"]).removeprefix(BASE)
print(browser.get(callback, base_url=BASE).status_code)
print(browser.get("/done", base_url=BASE).get_json())
Source: Flask-Dance: API Reference
['client_id', 'code_challenge', 'code_challenge_method', 'redirect_uri', 'response_type', 'scope', 'state']
302
{'email': 'ada@example.test', 'email_verified': True, 'iss': 'https://idp.example.test', 'name': 'Ada', 'sub': '1001'}
The list shows that this blueprint sends a code_challenge but no nonce. For anything more than "who is this
account" on a provider you control, use Authlib and let it validate the token.
Redirect URIs Behind a Proxy
The redirect URI that the application sends must match, character for character, one registered with the provider,
and it is built with url_for(..., _external=True) from the request the application sees. Behind a TLS-terminating
proxy that request is plain HTTP on an internal host name, so the generated address is http://10.0.0.5:8000/...,
the provider rejects it with a redirect mismatch, and (worse) a developer then "fixes" it by enabling plain HTTP.
The correct fix is to make Flask aware of the proxy with Werkzeug’s ProxyFix and the exact number of proxy hops you
control, so that X-Forwarded-Proto and X-Forwarded-Host are trusted from your proxy and from nobody else. Since
Flask 3.1 the TRUSTED_HOSTS setting also rejects requests whose Host is not on a list, which keeps a spoofed host
from ending up in a redirect URI. Details of deployment belong to the page about WSGI servers and proxies.
from werkzeug.middleware.proxy_fix import ProxyFix
proxied = create_app(idp.config())
proxied.wsgi_app = ProxyFix(proxied.wsgi_app, x_for=1, x_proto=1, x_host=1)
with provider_online(idp):
resp = proxied.test_client().get(
"/login/idp",
base_url="http://10.0.0.5:8000",
headers={"X-Forwarded-Proto": "https", "X-Forwarded-Host": "books.example.test"},
)
print(parse_qs(urlsplit(resp.headers["Location"]).query)["redirect_uri"])
['https://books.example.test/auth/idp/callback']
Logging Out
"Log out" means two different things with a social login: ending the Bookshelf session, and ending the session at the
provider. The first is session.clear() (or Flask-Login’s logout_user()) and is what /logout does; the user is
then anonymous on the Bookshelf, but one click on "Sign in with Google" signs them straight back in, because the
provider still remembers them and their consent. The second is OpenID Connect RP-initiated logout: the application
redirects to the provider’s end_session_endpoint with an id_token_hint and a registered
post_logout_redirect_uri, which Authlib’s logout_redirect() builds and validate_logout_response() checks on the
way back. Flask-Dance, and OAuth-only providers, have no such endpoint; the nearest equivalent is revoking the token
at the provider, which its documentation describes per provider. See
Logout and Session Management for the protocol side.
with provider_online(idp):
browser = app.test_client()
browser_login(browser, idp)
resp = browser.post("/logout/idp", base_url=BASE)
print(resp.status_code, urlsplit(resp.headers["Location"]).path, sorted(parse_qs(urlsplit(resp.headers["Location"]).query)))
print(browser.get("/me", base_url=BASE).status_code)
back = "/signed-out?" + urlsplit(resp.headers["Location"]).query.split("&")[-1]
print(browser.get(back, base_url=BASE).get_json())
302 /logout ['id_token_hint', 'post_logout_redirect_uri', 'state']
401
{'status': 'signed out'}
LDAP Authentication
LDAP is a protocol for reading and searching a directory: a tree of entries, each with a distinguished name (DN)
such as uid=ada,ou=people,dc=bookshelf,dc=example, and attributes such as mail and memberOf. Directories are
also authentication servers, because a client can bind (authenticate) as an entry with its password.
Active Directory speaks LDAP too, with sAMAccountName or userPrincipalName where OpenLDAP uses uid. The
cookbook recipe builds the DN by formatting the typed user name into a template and binds with it; that is the
mistake this section avoids. A user name that contains DN or filter metacharacters can change what is being asked of
the directory (LDAP injection), and a DN template also forces every user into one subtree.
The robust pattern is search then bind:
-
Bind as a dedicated, read-only service account (credentials from the environment).
-
Search for the single entry whose login attribute equals the typed name, with the name escaped for a filter.
-
Take the DN from the result and bind as that DN with the typed password; the directory’s answer is the verdict.
-
Read the attributes and group memberships you need, and unbind both connections.
The authenticator below implements it with ldap3. Four rules are encoded in it: an empty password is rejected before
any network call, because a simple bind with a DN and an empty password is an unauthenticated bind that many
servers answer with success (ldap3 raises LDAPPasswordIsMandatoryError instead of sending it; other clients send what they are given); the user name is escaped with
escape_filter_chars (RFC 4515) and limited in length; the search is limited to two results so an ambiguous match is
a failure rather than a coin toss; and an unreachable directory is a different error from wrong credentials, so that
an outage does not look like every user forgetting their password. Credentials for the service account are
passed in, not written in the code, and no password is ever logged. The connection factory shows the production
transport: LDAPS on port 636 with certificate validation required and a connect timeout.
import ssl
from ldap3 import MOCK_SYNC, SIMPLE, SUBTREE, SYNC, Connection, Server, Tls
from ldap3.core.exceptions import LDAPException
from ldap3.utils.conv import escape_filter_chars
class DirectoryUnavailable(Exception):
"""The directory could not be reached (not the same thing as wrong credentials)."""
@dataclass(frozen=True)
class DirectoryUser:
dn: str
uid: str
email: str | None
name: str | None
groups: tuple[str, ...]
class LdapAuthenticator:
"""Search-then-bind: find the user's DN with a service account, then bind as the user."""
def __init__(self, server, base_dn, service_dn, service_password, strategy=SYNC):
self.server = server
self.base_dn = base_dn
self.service_dn = service_dn
self.service_password = service_password
self.strategy = strategy
def _connection(self, user, password):
return Connection(self.server, user=user, password=password, authentication=SIMPLE,
client_strategy=self.strategy, receive_timeout=10)
def authenticate(self, username, password):
# An empty password turns a simple bind into an "unauthenticated bind", which many
# servers accept: refuse it before talking to the directory at all.
if not username or not password or len(username) > 64:
return None
search_filter = f"(&(objectClass=inetOrgPerson)(uid={escape_filter_chars(username)}))"
try:
service = self._connection(self.service_dn, self.service_password)
if not service.bind():
raise DirectoryUnavailable("service account bind failed")
try:
service.search(self.base_dn, search_filter, search_scope=SUBTREE,
attributes=["uid", "mail", "cn", "memberOf"], size_limit=2)
if len(service.entries) != 1: # unknown user, or an ambiguous match
return None
entry = service.entries[0]
finally:
service.unbind()
user_conn = self._connection(entry.entry_dn, password)
try:
if not user_conn.bind(): # wrong password
return None
finally:
user_conn.unbind()
except LDAPException as exc:
raise DirectoryUnavailable(type(exc).__name__) from exc
return DirectoryUser(entry.entry_dn, entry.uid.value, entry.mail.value, entry.cn.value,
tuple(entry.memberOf.values))
def server_from_env():
"""The production Server object: LDAPS with certificate validation (no connection is made here)."""
tls = Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=os.environ.get("LDAP_CA_FILE"))
return Server(os.environ.get("LDAP_HOST", "ldap.example.test"), port=636, use_ssl=True,
tls=tls, connect_timeout=5)
Source: ldap3: Bind
The ldap3 documentation describes MOCK_SYNC, a client strategy that emulates a simple LDAP server in memory: the
test populates the directory with add_entry(), and simple binds succeed only for entries that carry a clear-text
userPassword. It does no access control and does not validate entries against a schema, so it proves the
application’s logic (escaping, the two binds, the mapping) and not the server’s policy; run a few tests against a
disposable real OpenLDAP container before shipping. The test passwords are generated at run time.
BASE_DN = "ou=people,dc=bookshelf,dc=example"
SERVICE_DN = "cn=search,ou=system,dc=bookshelf,dc=example"
service_pw, ada_pw = secrets.token_urlsafe(16), secrets.token_urlsafe(16)
fake = Server("fake-directory")
seed = Connection(fake, user=SERVICE_DN, password=service_pw, client_strategy=MOCK_SYNC)
seed.strategy.add_entry(SERVICE_DN, {"userPassword": service_pw, "objectClass": ["person"]})
seed.strategy.add_entry(f"uid=ada,{BASE_DN}", {
"userPassword": ada_pw, "objectClass": ["inetOrgPerson"], "uid": "ada",
"cn": "Ada Lovelace", "mail": "ada@bookshelf.example",
"memberOf": ["cn=librarians,ou=groups,dc=bookshelf,dc=example"]})
auth = LdapAuthenticator(fake, BASE_DN, SERVICE_DN, service_pw, strategy=MOCK_SYNC)
print(auth.authenticate("ada", ada_pw))
print(auth.authenticate("ada", "wrong password"), auth.authenticate("ada", ""))
print(auth.authenticate("*", "x"), auth.authenticate("ada)(uid=*", "x"))
print(server_from_env())
down = LdapAuthenticator(Server("127.0.0.1", port=1, connect_timeout=1), BASE_DN, SERVICE_DN, service_pw)
try:
down.authenticate("ada", "x")
except DirectoryUnavailable as exc:
print("directory unavailable:", exc)
Source: ldap3: Mocking
DirectoryUser(dn='uid=ada,ou=people,dc=bookshelf,dc=example', uid='ada', email='ada@bookshelf.example', name='Ada Lovelace', groups=('cn=librarians,ou=groups,dc=bookshelf,dc=example',))
None None
None None
ldaps://ldap.example.test:636 - ssl
directory unavailable: LDAPSocketOpenError
The wildcard (*) and the injection attempt (ada)(uid=*) find nothing because the filter escaping turns the
metacharacters into \2a, \28 and \29, so they are matched as literal characters. Without the escaping the first
would match the first user in the directory and the second would rewrite the filter.
Escaping DNs and filters
A DN and a filter are two different grammars with two different escaping functions, and mixing them up is a bug.
| Where the untrusted text goes | ldap3 | python-ldap |
|---|---|---|
A filter value, |
|
|
An RDN value inside a DN, |
|
|
The search-then-bind pattern needs only the filter function, because the DN used to bind comes from the directory’s own answer and is never assembled from user input. Building a DN is needed only when creating or renaming entries, for example when the Bookshelf provisions accounts in a directory.
import ldap.dn
import ldap.filter
from ldap3.utils.dn import escape_rdn
hostile = 'ada,ou=admins+uid=root"*)('
print(escape_filter_chars(hostile))
print(ldap.filter.escape_filter_chars(hostile))
print(escape_rdn(hostile))
print(ldap.dn.escape_dn_chars(hostile))
Source: python-ldap: ldap.filter and python-ldap: ldap.dn
ada,ou=admins+uid=root"\2a\29\28
ada,ou=admins+uid=root"\2a\29\28
ada\,ou\=admins\+uid\=root\"*)(
ada\,ou\=admins\+uid\=root\"*)(
With python-ldap the same flow is written against its connection object. The bind needs a reachable server, so the
block is not executed on this page; the escaping calls it uses are the ones executed above. python-ldap needs the
OpenLDAP client libraries at build time, which is its main cost compared with pure-Python ldap3, and its TLS options are
set with set_option on the module or the connection before use.
# norun: needs a reachable directory server and the OpenLDAP client libraries
import ldap
import ldap.filter
def ldap_authenticate(username, password):
if not username or not password:
return None
conn = ldap.initialize(os.environ["LDAP_URI"]) # e.g. ldaps://ldap.example.test
conn.set_option(ldap.OPT_REFERRALS, 0)
conn.set_option(ldap.OPT_NETWORK_TIMEOUT, 5)
conn.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_DEMAND)
conn.set_option(ldap.OPT_X_TLS_NEWCTX, 0) # apply the TLS options above
try:
conn.simple_bind_s(os.environ["LDAP_SERVICE_DN"], os.environ["LDAP_SERVICE_PASSWORD"])
flt = f"(&(objectClass=inetOrgPerson)(uid={ldap.filter.escape_filter_chars(username)}))"
found = conn.search_s(os.environ["LDAP_BASE_DN"], ldap.SCOPE_SUBTREE, flt, ["uid", "mail", "cn"])
found = [(dn, attrs) for dn, attrs in found if dn] # drop referrals
if len(found) != 1:
return None
user_dn, attrs = found[0]
conn.simple_bind_s(user_dn, password) # raises INVALID_CREDENTIALS
return user_dn, attrs
except ldap.INVALID_CREDENTIALS:
return None
except ldap.SERVER_DOWN as exc:
raise DirectoryUnavailable("directory down") from exc
finally:
conn.unbind_s()
Source: python-ldap: ldap module
Transport, timeouts and the service account
| Concern | What to do |
|---|---|
Encryption |
Bind only over TLS: LDAPS ( |
Certificate checking |
ldap3’s default |
Timeouts |
|
Service account |
Read-only, allowed to search the user subtree and read only the attributes you list. Its password comes from the environment or a secret manager, and it is never the account of a person. |
Stable key |
Provision the local user by an immutable attribute such as OpenLDAP’s |
Local password |
Never copy the LDAP password into your own database "for later". The cookbook recipe creates a local user with the typed password; the directory is the only place that password may exist. |
Throttling |
A login form that forwards passwords to a directory is a password-guessing oracle. Rate limit by user and by address (Flask-Limiter is the usual extension) and let the directory’s own lockout policy apply. |
From LDAP groups to Flask roles and a login view
The memberOf attribute (an overlay in OpenLDAP, native in Active Directory) lists the groups of a user. A fixed
mapping from group DN to application role keeps authorisation out of the directory’s naming, and the roles then drive
the checks described in Authorization & Roles. The view creates a
brand new session once the directory has said yes. A real form must also be protected against cross-site request forgery
(see Forms with Flask-WTF), and the whole login should run over HTTPS.
ROLE_MAP = {"cn=librarians,ou=groups,dc=bookshelf,dc=example": "librarian"}
def create_ldap_app(authenticator):
ldap_app = Flask("bookshelf-ldap")
ldap_app.config.update(SECRET_KEY=secrets.token_hex(32), SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax")
@ldap_app.post("/login/ldap")
def ldap_login():
username = request.form.get("username", "")
password = request.form.get("password", "") # never log this value
try:
user = authenticator.authenticate(username, password)
except DirectoryUnavailable:
return jsonify(error="directory_unavailable"), 503
if user is None:
return jsonify(error="invalid_credentials"), 401 # same answer for unknown user and wrong password
roles = sorted({ROLE_MAP[g] for g in user.groups if g in ROLE_MAP})
session.clear()
session.update(ldap_dn=user.dn, roles=roles)
return jsonify(user=user.uid, roles=roles)
return ldap_app
ldap_app = create_ldap_app(auth)
browser = ldap_app.test_client()
ok = browser.post("/login/ldap", data={"username": "ada", "password": ada_pw}, base_url=BASE)
bad = browser.post("/login/ldap", data={"username": "ada", "password": "nope"}, base_url=BASE)
print(ok.status_code, ok.get_json())
print(bad.status_code, bad.get_json())
Source: Flask API: flask.session
200 {'roles': ['librarian'], 'user': 'ada'}
401 {'error': 'invalid_credentials'}
To connect this to Flask-Login, replace the two session lines by a call to login_user() on the local user that you
find or create from the directory’s immutable identifier, exactly as the social callback ends; the rest of the
application then sees one kind of logged-in user whatever route they came through.
Security Checklist
| Risk | Defence on this page |
|---|---|
Login CSRF and authorization-code injection |
A random |
Authorization-code interception |
PKCE with |
ID token replay or substitution |
Authlib validates the signature, |
Account takeover through e-mail matching |
Key identities on ( |
Session fixation |
|
Open redirect after login |
|
Callback losing the session |
|
Wrong redirect URI behind a proxy |
|
LDAP injection |
|
Unauthenticated bind |
Reject empty passwords before binding. |
Plain-text credentials on the wire |
LDAPS or StartTLS with certificate validation required. |
Secrets in the repository |
Client secrets, the Flask |
Enumerating users |
One error message and the same status code for "unknown user" and "wrong password". |
The OWASP Cheat Sheet Series has matching references for this area: the OAuth 2.0 Protocol Cheat Sheet, the LDAP Injection Prevention Cheat Sheet and the Authentication Cheat Sheet. The current OAuth 2.0 security recommendations are RFC 9700, summarised on OAuth Security Best Practices.
Recent Changes and Where the Books Are Outdated
The cookbook (Flask Framework Cookbook, chapter 6, "Authenticating in Flask") builds Facebook, Google and Twitter logins with Flask-Dance and an LDAP login with python-ldap. The concepts survive; the details do not.
| What the book shows | Today |
|---|---|
|
Flask-Dance 7.1.0 ships no Twitter module. The v1.1 API behind the recipe is gone; use X’s OAuth 2.0 with PKCE through Authlib or the generic blueprint. |
Facebook and Google blueprints configured through two |
The keys are right ( |
Google login through an OAuth 2.0 profile scope only |
Use OpenID Connect ( |
A local HTTPS server with an ad hoc certificate to make the callback work |
Still the way to test on a laptop, but behind a real proxy the fix is |
LDAP bind with |
Vulnerable to DN injection and tied to one subtree. Use search-then-bind with a filter-escaped search. |
Creating the local user with the LDAP password after a successful bind |
Never store the directory password; store an immutable identifier and let the directory keep verifying it. |
An OpenLDAP container as the test server, and python-ldap only |
Still useful for integration tests; add unit tests with ldap3’s |
Facebook and Google as the only "social" providers |
GitHub, Microsoft, Apple and others are equally common; provider-specific quirks are in Social Login and Federation. |
Library changes worth knowing: Authlib 1.8 moved its JOSE implementation to the joserfc package and marks
authlib.jose as deprecated, so ID token errors are joserfc.errors.JoseError subclasses; Flask-Dance has had no
release since March 2024; ldap3 has had none since July 2021; python-ldap continues to be released. For the machinery
that sits around all of this, see Sessions, Cookies & Flashing,
Authentication & Flask-Login and
Security Considerations; token-based API authentication (JWT
validation, API keys) is covered by the page about JWT and API authentication.
Summary
-
Delegate sign-in to a provider with Authlib’s
OAuthregistry: configure<NAME>_CLIENT_ID,<NAME>_CLIENT_SECRETand<NAME>_SERVER_METADATA_URLfrom the environment, callauthorize_redirect()andauthorize_access_token(), and read validated claims fromtoken["userinfo"]. -
Authlib creates and checks
state,nonceand PKCE (code_challenge_method="S256"); catchOAuthErrorandJoseError, and show one generic failure page. -
Link identities by (
iss,sub), use e-mail only when verified, and never merge accounts silently. -
OAuth-only providers such as GitHub need an API call to learn who the user is; ask for PKCE and read the verified primary e-mail from
/user/emails. -
Flask-Dance is a quick blueprint with token storage and signals but no ID token validation, a comma-separated scope quirk for GitHub, no handling of
MismatchingStateErrorand no Twitter module; its last release is from March 2024. -
Behind a proxy use
ProxyFixso redirect URIs are HTTPS and exact; keep the session cookieLax. -
For LDAP use search-then-bind with
escape_filter_chars, reject empty passwords, require TLS with certificate validation, separate "directory down" from "wrong password", and mapmemberOfgroups to roles. -
Test with fakes:
responsesplus a small provider application, and ldap3’sMOCK_SYNC; confirm against the real services before release.