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 iss, sub, aud, exp, nonce and profile claims, plus an access token

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.

sequenceDiagram participant B as Browser participant F as Flask Bookshelf participant P as Identity provider B->>F: GET /login/idp F->>F: create state, nonce and PKCE verifier and keep them in the session F-->>B: 302 to the provider with state, nonce and code_challenge B->>P: user signs in and approves P-->>B: 302 to the callback with code and state B->>F: GET /auth/idp/callback with the session cookie F->>F: compare state with the saved value and drop it F->>P: POST token endpoint with code, code_verifier and client credentials P-->>F: access token and signed ID token F->>P: GET jwks_uri if the signing key is not cached F->>F: check signature, iss, aud, exp and nonce F->>F: map iss and sub to a local user and start a new session F-->>B: 302 to the page the user wanted
Figure 1. The OpenID Connect login flow with Authlib in Flask

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 (authlib.integrations.flask_client)

Provider registry configured from app.config; discovery documents; state, nonce and PKCE; ID token validation through JOSE (Authlib 1.8 uses the joserfc package); token refresh hooks; RP-initiated logout; also OAuth 1.0

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; use_pkce exists only on the generic blueprint class

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

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

client_id, client_secret (<NAME>_CLIENT_ID, <NAME>_CLIENT_SECRET)

The credentials issued by the provider when you registered the application. Keep them in the environment or a secret store, never in the repository.

server_metadata_url

URL of the provider’s OpenID Connect discovery document (/.well-known/openid-configuration). From it Authlib reads the authorization, token and JWKS endpoints, the issuer and the supported signing algorithms. For Google it is https://accounts.google.com/.well-known/openid-configuration.

authorize_url, access_token_url, api_base_url

The manual alternative for providers without discovery, such as GitHub. api_base_url lets client.get("user") build the full address.

client_kwargs

Extra settings for the underlying OAuth 2 session: scope, code_challenge_method (set S256 to turn PKCE on), token_endpoint_auth_method (client_secret_basic, client_secret_post or none).

authorize_params, access_token_params

Extra query or body parameters for those endpoints, for example prompt or access_type.

OAuth(app, cache=...)

Where the per-login state is kept. By default it is the Flask session cookie; a cache object with get, set and delete (any cachelib cache) moves it to the server.

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

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

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())

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)
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)))
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())
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 scope value. oauthlib compares the granted scope with the requested one and raises a Warning ("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 variable OAUTHLIB_RELAX_TOKEN_SCOPE is 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 state raises oauthlib’s MismatchingStateError, a subclass of OAuth2Error, from the callback view; without the error handler above it is a 500. Provider-side errors such as access_denied are sent through the oauth_error signal, and the user is redirected to redirect_url anyway unless a receiver returns a Response.

  • Plain HTTP is refused. oauthlib raises InsecureTransportError for non-HTTPS URLs. Development servers that use HTTP set OAUTHLIB_INSECURE_TRANSPORT=1 in 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 SessionStorage keeps 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())
['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:

  1. Bind as a dedicated, read-only service account (credentials from the environment).

  2. Search for the single entry whose login attribute equals the typed name, with the name escaped for a filter.

  3. Take the DN from the result and bind as that DN with the typed password; the directory’s answer is the verdict.

  4. Read the attributes and group memberships you need, and unbind both connections.

flowchart TD A[Login form: username and password] --> B{Both fields filled in?} B -- no --> X[401 invalid credentials] B -- yes --> C[Bind as the service account over TLS] C --> D[Search uid with the escaped username] D --> E{Exactly one entry?} E -- no --> X E -- yes --> F[Bind as the entry DN with the typed password] F --> G{Bind succeeded?} G -- no --> X G -- yes --> H[Map memberOf groups to roles] H --> I[Create the Flask session] C -. directory down .-> Y[503 try again later]
Figure 2. Search-then-bind LDAP authentication in a Flask login view

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, (uid=<here>) (RFC 4515)

ldap3.utils.conv.escape_filter_chars

ldap.filter.escape_filter_chars

An RDN value inside a DN, uid=<here>,ou=people,... (RFC 4514)

ldap3.utils.dn.escape_rdn

ldap.dn.escape_dn_chars

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))
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()

Transport, timeouts and the service account

Concern What to do

Encryption

Bind only over TLS: LDAPS (Server(host, use_ssl=True), port 636) or StartTLS on port 389 (AUTO_BIND_TLS_BEFORE_BIND as the auto_bind argument in ldap3, start_tls_s() in python-ldap). A simple bind sends the password in clear text otherwise.

Certificate checking

ldap3’s default Tls object performs no validation of the server certificate when use_ssl=True; pass a Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=...) as in server_from_env(). In python-ldap use OPT_X_TLS_REQUIRE_CERT with OPT_X_TLS_DEMAND and a CA file.

Timeouts

connect_timeout on the ldap3 Server, receive_timeout on the Connection, OPT_NETWORK_TIMEOUT in python-ldap. Without them a dead directory hangs every worker thread that attempts a login.

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 entryUUID or Active Directory’s objectGUID, not by DN or by uid: DNs change when people move between departments, and names are sometimes reused.

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())
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 state bound to the session, removed on first use (Authlib and Flask-Dance do it; test it as above).

Authorization-code interception

PKCE with S256 (code_challenge_method in Authlib, use_pkce=True in Flask-Dance’s generic blueprint); a confidential client also authenticates at the token endpoint.

ID token replay or substitution

Authlib validates the signature, iss, aud, exp and the nonce. Flask-Dance does not, so do not treat its userinfo answer as a signed assertion.

Account takeover through e-mail matching

Key identities on (iss, sub), use e-mail only when verified, and require a signed-in session to link.

Session fixation

session.clear() when the user becomes known, and a new session identifier with server-side sessions.

Open redirect after login

safe_next(): same-site absolute paths only.

Callback losing the session

SESSION_COOKIE_SAMESITE="Lax", not "Strict"; consistent host name between login and callback.

Wrong redirect URI behind a proxy

ProxyFix with exact hop counts and TRUSTED_HOSTS; never OAUTHLIB_INSECURE_TRANSPORT in production.

LDAP injection

escape_filter_chars for filter values, escape_rdn or escape_dn_chars for DN components, DN taken from the directory’s search result.

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 SECRET_KEY and the LDAP service password come from the environment or a secret store; every credential in the examples is generated when they run.

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

make_twitter_blueprint and a Twitter login

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 app.config keys with strings typed into the code

The keys are right (FACEBOOK_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_ID and their secrets) but they should come from the environment; the consoles of both providers have been redesigned several times, so follow their current documentation for registering an app and its redirect URIs.

Google login through an OAuth 2.0 profile scope only

Use OpenID Connect (openid email profile) with a library that validates the ID token, such as Authlib.

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 ProxyFix and a correct external URL, and OAUTHLIB_INSECURE_TRANSPORT stays a local-development switch.

LDAP bind with "cn=%s,dc=example,dc=org" % username

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 MOCK_SYNC. python-ldap needs the C client libraries.

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 OAuth registry: configure <NAME>_CLIENT_ID, <NAME>_CLIENT_SECRET and <NAME>_SERVER_METADATA_URL from the environment, call authorize_redirect() and authorize_access_token(), and read validated claims from token["userinfo"].

  • Authlib creates and checks state, nonce and PKCE (code_challenge_method="S256"); catch OAuthError and JoseError, 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 MismatchingStateError and no Twitter module; its last release is from March 2024.

  • Behind a proxy use ProxyFix so redirect URIs are HTTPS and exact; keep the session cookie Lax.

  • 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 map memberOf groups to roles.

  • Test with fakes: responses plus a small provider application, and ldap3’s MOCK_SYNC; confirm against the real services before release.

References