A minimal, educational SAML 2.0 Identity Provider built with Python/Flask. Authenticates users from a CSV file, maintains sessions via signed JWT cookies (fully stateless), and produces signed SAML assertions.
⚠️ Educational prototype — not for production.This code is written to be readable, not bulletproof. It runs on plain HTTP, has no rate limiting on login, does not validate
AssertionConsumerServiceURLagainst a registered whitelist, ships SCIM without auth by default, and uses a CSV-backed user store with no concurrency control. Use it to learn SAML, then throw it away. See the Security limitations section before doing anything non-trivial with it.Licensed under the MIT License. See LICENSE.
- Python 3.11+
- uv —
curl -LsSf https://astral.sh/uv/install.sh | sh
# One-shot setup (installs deps into .venv via uv, generates certs, adds sample users)
make setup
# Configure environment
cp .env.example .env
# Edit .env and set JWT_SECRET to a random value
# Run the IdP
make runThe IdP starts on http://localhost:5001.
make help # Show all targets
make install # Install dependencies (uv sync)
make certs # Generate self-signed certs
make certs-force # Regenerate certs
make users # Add sample users (alice, bob)
make run # Run the IdP server
make test # Run the pytest test suite
make clean # Remove certs and users.csv
make clean-all # Also remove .venv
| Endpoint | Method | Description |
|---|---|---|
/metadata |
GET | SAML metadata XML |
/sso |
GET/POST | SSO endpoint (HTTP-Redirect & HTTP-POST bindings) |
/sso/login |
POST | Login form handler |
/slo |
GET/POST | Single Logout |
/logout |
GET | Local logout (clears session cookie) |
/scim/v2/Users/<email> |
DELETE | SCIM user deletion (bearer auth) |
/scim/v2/Users/<email> |
PATCH | SCIM user deactivation (bearer auth) |
SCIM endpoints require Authorization: Bearer <SCIM_BEARER_TOKEN> when
that env var is set. If unset, SCIM is unauthenticated and the server
prints a warning at startup.
make testThe tests/ directory is written as executable documentation. Test
names describe SAML properties and attack scenarios, test bodies read
like spec vignettes. Notable cases:
test_signed_assertion_verifies_with_matching_cert— happy path.test_tampered_attribute_fails_verification— detect MITM edits.test_wrong_cert_fails_verification— reject rogue IdP.test_sp_side_check_would_reject_expired_timestamp— validity window.test_sp_side_audience_check— reject misrouted assertions.test_deactivated_user_cannot_login— SCIM deprovisioning teeth.test_password_is_stored_as_bcrypt_hash— no plaintext on disk.
Read them top-to-bottom for a guided tour of SAML security properties.
examples/sp_verifier.py is a small, annotated script that performs the
seven checks a real Service Provider must do before honoring a SAML
Response. Point it at a captured Response to see each step printed:
python examples/sp_verifier.py "$SAML_RESPONSE_B64" \
--cert certs/idp_cert.pem \
--expected-audience http://sp.example.com \
--expected-acs https://sp.example.com/acs \
--expected-request-id _my_request_idThe script exits non-zero on any failed check and prints exactly which
one. Try tampering with the base64 body to see the signature check catch
it (see ## Attack examples below).
The canonical way to internalize a security protocol is to break it
deliberately. Each attack below assumes a captured valid SAMLResponse
(base64) — see docs/transcript.md for how to produce one with curl.
Assume in your shell:
SAML="<paste captured base64 SAMLResponse>"
EXPECTED_REQ=_sp_verifier_demo # ID from the AuthnRequest that produced itAn assertion issued for sp.example.com is intercepted and presented to
evil-sp.example.com. The signature is still valid (bytes are unchanged),
so the attacker hopes the target SP will accept it.
The AudienceRestriction check stops this:
python examples/sp_verifier.py "$SAML" \
--cert certs/idp_cert.pem \
--expected-audience http://evil-sp.example.com \
--expected-acs https://sp.example.com/acs \
--expected-request-id $EXPECTED_REQ
# ✗ VERIFICATION FAILED: our audience 'http://evil-sp.example.com'
# not in ['http://sp.example.com']Why it works in defense: the IdP binds the assertion to a specific SP
via <Audience>. The SP's job is to require its own entityID to be
listed — skipping this check is a well-known class of CVE.
An assertion is intercepted and replayed into an SSO flow the victim
didn't initiate. Both the <Response> and the
<SubjectConfirmationData> carry InResponseTo. The SP holds a
pending-request cache and rejects if the values don't match a live entry.
python examples/sp_verifier.py "$SAML" \
--cert certs/idp_cert.pem \
--expected-audience http://sp.example.com \
--expected-acs https://sp.example.com/acs \
--expected-request-id _some_other_request
# ✗ VERIFICATION FAILED: Response InResponseTo '_sp_verifier_demo'
# != expected '_some_other_request'Why it matters: without this, a captured assertion could be replayed
indefinitely. Pairing InResponseTo with short NotOnOrAfter windows
and a one-shot pending-request cache closes the replay window.
An attacker edits the plaintext Assertion to elevate privileges —
changing Alice Smith to Admin Attacker, or adding a roles=superuser
entry. Any byte change invalidates the XML signature because the
<ds:DigestValue> no longer matches.
# Replace 'Alice Smith' with 'Evil Attack1' (same length) in raw XML
python -c "
import base64, sys
b = base64.b64decode(sys.argv[1])
b2 = b.replace(b'Alice Smith', b'Evil Attack1')
print(base64.b64encode(b2).decode())
" "$SAML" > /tmp/tampered.txt
TAMPERED=$(cat /tmp/tampered.txt)
python examples/sp_verifier.py "$TAMPERED" \
--cert certs/idp_cert.pem \
--expected-audience http://sp.example.com \
--expected-acs https://sp.example.com/acs \
--expected-request-id $EXPECTED_REQ
# ✗ VERIFICATION FAILED: signature verification failed:
# Digest mismatch for reference 0 (#_xxx)Why it works in defense: XMLDSig signs a canonicalized digest of the Assertion subtree. Flipping any byte (including attribute names, whitespace inside elements, or namespace declarations) changes the digest; the signature math fails.
A more sophisticated class of attacks, XML Signature Wrapping (XSW),
tries to smuggle an attacker-controlled <Assertion> past the signature
check by placing it in a location the parser picks up but the verifier
doesn't. Defending against XSW requires strict "verify and extract the
SAME element" discipline — see saml/assertion.py for notes. Our
sp_verifier.py uses signxml's signed_xml return value, which is
the only trusted bytes, as defense against XSW.
Even without edits, an assertion captured hours ago should not be
accepted. The Conditions.NotOnOrAfter window (default 5 minutes here)
bounds the attacker's window. Replay after expiry:
# Wait 6+ minutes with a captured response, then re-run sp_verifier.py.
# ✗ VERIFICATION FAILED: assertion expired: now=..., NotOnOrAfter=...Short windows are the simplest replay defense. Combined with the pending-request cache from attack #2, a single assertion is strictly one-shot and short-lived.
Real-world SAML has a few more edges worth noting:
- AuthnRequest spoofing: by default, anyone can send an AuthnRequest claiming to be any SP. See the next section for how to turn on signed AuthnRequest validation in this prototype.
- Metadata poisoning: if you fetch IdP metadata over plain HTTP, an attacker can swap the signing cert. Production federations serve metadata signed by the federation operator and verified out-of-band.
- Encrypted assertions: for confidentiality from an intermediary, the IdP can encrypt the Assertion to the SP's public key. Not done here (most modern deployments rely on TLS end-to-end instead).
By default this IdP trusts any incoming AuthnRequest — good for
experimentation, terrible for production. To flip the trust model so
only pre-registered SPs can initiate SSO, and each must sign its request:
-
Register the SP in
sp_registry.json:{ "service_providers": [ { "entity_id": "http://sp.example.com", "cert_path": "certs/sp_cert.pem" } ] } -
Enable the flag:
REQUIRE_SIGNED_AUTHN_REQUESTS=true python app.py
-
Metadata now advertises
WantAuthnRequestsSigned="true", informing SPs they must sign.
Two very different signature mechanisms apply depending on the
binding the SP uses (see saml/authn_request_verify.py for detailed
spec-anchored comments):
- HTTP-Redirect: signature is over the raw URL query string octets,
appended as
&Signature=<base64>. See SAML Bindings §3.4.4.1. - HTTP-POST: signature is an enveloped
<ds:Signature>inside the AuthnRequest XML (same mechanism as our Response signature).
Trust boundary: unregistered issuers are rejected outright — the IdP has no way to know whose cert to trust otherwise. This is the SP's analog of the SP-side cert pinning discussed in the attack examples.
- Start the IdP:
python app.py - Make metadata accessible (use ngrok or similar for public URL)
- Upload metadata at https://samltest.id/upload.php
- Start IdP test at https://samltest.id/start-idp-test/
- Login with sample credentials
- Verify assertion attributes are displayed
SP-initiated Web SSO with HTTP-Redirect binding (request) and HTTP-POST binding (response). This is the SAML Web Browser SSO Profile (SAML Profiles §4.1).
sequenceDiagram
autonumber
actor U as User (browser)
participant SP as Service Provider
participant I as IdP (this app)
U->>SP: 1. GET /protected
SP-->>U: 2. 302 Redirect to IdP<br/>?SAMLRequest=base64(deflate(AuthnRequest))<br/>&RelayState=/protected
U->>I: 3. GET /sso?SAMLRequest=...&RelayState=...
Note over I: parse_authn_request()<br/>base64 decode + zlib raw-deflate<br/>→ {id, acs_url, issuer}
alt No JWT cookie
I-->>U: 4. 200 login.html
U->>I: 5. POST /sso/login (email, password, SAMLRequest, RelayState)
Note over I: validate_credentials() — bcrypt.checkpw
else Has valid JWT cookie
Note over I: get_current_user() — decode JWT
end
Note over I: build_saml_assertion()<br/>Issuer → Signature(placeholder) → Subject<br/>→ Conditions → AuthnStatement → AttributeStatement
Note over I: sign_assertion()<br/>XMLSigner(enveloped, RSA-SHA256, Exclusive C14N)<br/>replaces placeholder with real ds:Signature
Note over I: build_saml_response()<br/>samlp:Response wraps signed Assertion,<br/>adds Status(Success), base64-encode
I-->>U: 6. 200 auto_post.html (Set-Cookie: idp_session=JWT)<br/>form action=ACS, hidden SAMLResponse + RelayState
Note over U: onload="document.forms[0].submit()"
U->>SP: 7. POST ACS (SAMLResponse, RelayState)
Note over SP: base64 decode → verify ds:Signature<br/>check Audience, NotBefore/NotOnOrAfter,<br/>InResponseTo, Recipient, Destination
SP-->>U: 8. 302 Redirect to RelayState (/protected)
U->>SP: 9. GET /protected (authenticated)
SP-->>U: 10. 200 protected content
Key observations:
- Steps 1-2: SP bounces the user to us. The
SAMLRequestis deflate-compressed to fit in URL length limits. - Step 3: We parse the AuthnRequest to learn the SP's identity (
Issuer), where to send the response (AssertionConsumerServiceURL), and the request ID to echo back (InResponseTo). - Steps 4-5: Login only happens if the user has no valid IdP session cookie. After login, subsequent SSO flows to other SPs are transparent.
- Step 6: The IdP never talks to the SP directly. All SAML traffic is carried by the user's browser — "via front channel". This is what lets SAML work across firewalls.
- Step 7: The auto-POST form uses
<body onload="document.forms[0].submit()">so the user experiences a single redirect. With JavaScript disabled, they see a "Continue" button (noscript fallback).
SCIM endpoints require Authorization: Bearer $SCIM_BEARER_TOKEN when
the env var is set. If unset, the header is not checked (dev only — the
server prints a warning at boot).
export TOKEN=$SCIM_BEARER_TOKEN # whatever you set in .envcurl -X DELETE http://localhost:5001/scim/v2/Users/bob@example.com \
-H "Authorization: Bearer $TOKEN"
# Returns: 204 No Contentcurl -X PATCH http://localhost:5001/scim/v2/Users/bob@example.com \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [{"op": "replace", "path": "active", "value": false}]
}'Configure webhook URLs in webhooks.json:
[
{
"url": "http://localhost:9000/webhook",
"events": ["user.deleted", "user.deactivated"]
}
]Events fire on SCIM operations with a JSON payload:
{
"event": "user.deleted",
"timestamp": "2025-01-15T10:30:00+00:00",
"user": { "email": "bob@example.com", "display_name": "Bob Jones", "roles": ["user"], "active": true }
}- Stateless sessions: JWT cookies — no server-side session store
- User store: CSV file with bcrypt-hashed passwords
- SAML signing: RSA-SHA256 enveloped signatures via signxml
- Certificates: Auto-generated self-signed RSA 2048 (10-year validity)
Read these before exposing this service to anything but localhost.
Transport & session
- No HTTPS out of the box. Credentials and assertions travel in
plaintext. Terminate TLS in front of this service (nginx, Caddy,
cloud LB) and set
IDP_SSO_URL=https://…soCOOKIE_SECUREauto-enables. JWT_SECRETauto-generated if env var unset. Every restart invalidates all live sessions. Set a persistent value in.env.
Trust boundaries
AssertionConsumerServiceURLis trusted blindly. A malicious SP could direct the auto-POST form to any URL. A real IdP validates this against a pre-registered list from SP metadata.WantAuthnRequestsSigned=falseby default. Anyone can initiate SSO on behalf of any SP. FlipREQUIRE_SIGNED_AUTHN_REQUESTS=trueand register SPs insp_registry.jsonto close this.- Metadata served over HTTP. An attacker could swap the cert. Real federations serve signed metadata over HTTPS and pin it out-of-band.
- Single signing cert for the whole IdP. No key rotation support.
Login surface
- No rate limiting / lockout on
/sso/login. Brute-forcing is possible. - No CSRF token on the login form. Lower-impact than on an authenticated app, but still worth adding.
- Usernames are enumeration-safe (login returns the same error for unknown user and wrong password) — that part is fine.
SCIM & user store
- SCIM unauthenticated by default. Set
SCIM_BEARER_TOKENfor any network-reachable deployment. - CSV-backed store with no concurrency control. Two simultaneous writes can lose data. Use a real database.
- No audit log of logins, deactivations, or webhook dispatches.
Protocol edge cases we skip
- No XML Schema validation on inbound AuthnRequests.
- No XML Signature Wrapping (XSW) defense beyond "trust only the
element signxml returns as
signed_xml". - No encrypted assertions (relies on transport TLS for confidentiality).
- No replay cache —
InResponseTouniqueness andNotOnOrAfterwindows are your only defenses. - Single Logout is a minimal cookie-clear, not true federated SLO.
See the Attack examples section for hands-on demos of what the SP-side checks catch and what they don't.