DiscourseConnect SSO — Working Flask + Express Provider with HMAC Done Right
Discourse renamed "Discourse SSO" to DiscourseConnect in 2021, but every other blog post on the internet still calls it the old name — and the signing flow is delicate enough that a single base64 encoding mistake gives you the dreaded "Bad signature for payload" screen. This guide is a working reference: a Flask provider, an Express provider, an ASCII sequence diagram, and a field-by-field debug map for the four errors you will actually hit.
TL;DR
- DiscourseConnect = Discourse SSO. Same protocol, just a 2021 rebrand. Old guides use both terms interchangeably; the Ruby class is still called
SingleSignOn. - Two directions. Discourse as RP (your app authenticates users; most common) or Discourse as IdP (Discourse owns identities, satellite apps consume them via
enable_discourse_connect_provider). - Wire format: URL-encoded payload → base64 → HMAC-SHA256 with shared secret → hex digest. Both
ssoandsigride as query params. Nonce TTL is 10 minutes by default. - The mistake everyone makes: signing the decoded payload instead of the base64 string. The HMAC is computed over the base64 representation, not the raw URL-encoded payload.
DiscourseConnect vs Discourse SSO — Same Thing
If you searched "discourse sso", "sso discourse", or "discourse single sign on" and ended up here while half the documentation calls it "DiscourseConnect": both are the same protocol. Discourse renamed it in August 2021 to avoid confusion with industry-standard SAML/OAuth SSO. The name change touched:
sso_secret→discourse_connect_secretsso_url→discourse_connect_urlenable_sso→enable_discourse_connectenable_sso_provider→enable_discourse_connect_provider
The endpoints (/session/sso, /session/sso_login, /session/sso_provider), the payload format, and the signing algorithm are unchanged. The Ruby implementation file is still lib/single_sign_on.rb. Old admin settings names continue to work as aliases. So when a 2018 blog post tells you to set sso_secret, that's the same thing as the 2026 admin field discourse_connect_secret.
Two Directions: Discourse as RP vs Discourse as IdP
Before writing any code, decide who owns the user accounts. DiscourseConnect supports both roles:
| Mode | Who owns identities | Use when | Admin setting |
|---|---|---|---|
| Discourse as RP (Relying Party) | Your app | You already have a SaaS / WordPress / Rails app and want to add a forum that uses your existing accounts | enable_discourse_connect + discourse_connect_url + discourse_connect_secret |
| Discourse as IdP (Identity Provider) | Discourse | Your forum predates other tools; you want satellite apps (knowledge base, marketplace, voting tool) to share Discourse accounts | enable_discourse_connect_provider + discourse_connect_provider_secrets |
Most teams want RP. The rest of this article focuses on writing a provider (the RP case — your app issues the identity) and then briefly covers IdP at the end.
The Signing Protocol
Both directions use the same wire format:
1. Build a query-string payload:
[email protected]&external_id=42
2. Base64-encode the bytes of that string:
bm9uY2U9Y2I2ODI1MWVlZmI1MjExZTU4YzAwZmYxMzk1ZjBjMGImZW1haWw9...
3. Compute HMAC-SHA256 over the BASE64 STRING (not the decoded payload),
using the shared secret as the key. Take the hex digest:
2828aa29899722b35a2f191d34ef9b3ce695e0e6eeec47deb46d588d70c7cb56
4. URL-encode and ship as two query params:
?sso=bm9uY2U9Y2I2ODI1...&sig=2828aa29899722b35a2f...
The verifier runs steps 2–3 in reverse on the received sso value and compares the computed sig against the received sig. If they match, the payload is trusted. Then it base64-decodes sso, parses the URL-encoded fields, and uses them.
Required and optional payload fields
| Field | Direction | Required | Notes |
|---|---|---|---|
nonce | Both | Yes | Discourse generates on /session/sso, provider must echo back unchanged. Single-use, 10-min TTL. |
return_sso_url | Discourse → provider | Yes | Where the provider redirects after auth. Always /session/sso_login. |
email | Provider → Discourse | Yes | Verified email. Discourse trusts it as confirmed. |
external_id | Provider → Discourse | Yes | Stable user ID in your system. Never reuse across users. |
username | Provider → Discourse | No | Suggested username. Discourse normalizes (lowercase, strip). |
name | Provider → Discourse | No | Display name. |
avatar_url | Provider → Discourse | No | HTTPS URL to a square image. |
bio | Provider → Discourse | No | User bio / about-me text. |
admin | Provider → Discourse | No | true/false. Promotes the user. |
moderator | Provider → Discourse | No | true/false. |
add_groups | Provider → Discourse | No | Comma-separated group names to add the user to. |
remove_groups | Provider → Discourse | No | Comma-separated group names to remove. |
require_activation | Provider → Discourse | No | true if email is unverified — Discourse will send activation. |
suppress_welcome_message | Provider → Discourse | No | true to skip the new-user welcome PM. |
override_username | Provider → Discourse | No | true to update on every login (sync). |
override_name | Provider → Discourse | No | true to overwrite display name. |
override_avatar | Provider → Discourse | No | true to refetch avatar. |
logout | Provider → Discourse | No | true to log the user out (used in logout flow). |
The Redirect Dance — ASCII Sequence Diagram
The full flow has six round trips. The user always thinks they "clicked Login on Discourse" but the actual path is:
Notice the nonce travels in both directions: Discourse generates it, includes it in the outbound payload, and the provider must echo it back unchanged in the response. If the provider strips, regenerates, or forgets the nonce, Discourse rejects with "Nonce mismatch".
Provider Implementation: Flask (Python)
Here is a complete working Flask provider. Drop it in app.py, set DISCOURSE_SECRET and DISCOURSE_URL as env vars, and run with flask run. Replace the stub authenticate_user() with your real auth logic.
import os
import hmac
import hashlib
import base64
from urllib.parse import parse_qs, urlencode
from flask import Flask, request, redirect, session, render_template_string, abort
app = Flask(__name__)
app.secret_key = os.environ["FLASK_SECRET"]
DISCOURSE_SECRET = os.environ["DISCOURSE_SECRET"].encode("utf-8")
DISCOURSE_URL = os.environ["DISCOURSE_URL"] # e.g. https://forum.example.com
def sign(payload_b64: bytes) -> str:
"""HMAC-SHA256 of the base64 payload bytes, hex digest."""
return hmac.new(DISCOURSE_SECRET, payload_b64, hashlib.sha256).hexdigest()
@app.route("/sso", methods=["GET"])
def sso_entry():
sso = request.args.get("sso", "")
sig = request.args.get("sig", "")
# 1. Verify Discourse signed this request with our shared secret.
expected = sign(sso.encode("utf-8"))
if not hmac.compare_digest(expected, sig):
abort(403, "Bad signature on inbound request")
# 2. Decode the payload and extract the nonce + return_sso_url.
decoded = base64.b64decode(sso).decode("utf-8")
fields = {k: v[0] for k, v in parse_qs(decoded).items()}
session["sso_nonce"] = fields["nonce"]
session["sso_return_url"] = fields["return_sso_url"]
# 3. If user is already logged in, finish immediately. Else show login.
if "user_id" in session:
return _redirect_back_to_discourse(session["user_id"])
return render_template_string(LOGIN_FORM)
@app.route("/login", methods=["POST"])
def do_login():
user = authenticate_user(request.form["email"], request.form["password"])
if not user:
abort(401)
session["user_id"] = user["id"]
return _redirect_back_to_discourse(user["id"])
def _redirect_back_to_discourse(user_id: int):
user = lookup_user(user_id)
payload = {
"nonce": session["sso_nonce"], # echo back unchanged
"email": user["email"],
"external_id": str(user["id"]),
"username": user["username"],
"name": user["full_name"],
"avatar_url": user.get("avatar_url", ""),
}
encoded = urlencode(payload).encode("utf-8")
sso_b64 = base64.b64encode(encoded)
sig = sign(sso_b64)
return redirect(
f"{session['sso_return_url']}?sso={sso_b64.decode()}&sig={sig}"
)
# ── Stubs — replace with your auth + user lookup ──────────────────
def authenticate_user(email, password):
# query DB, verify bcrypt hash, etc.
return {"id": 42, "email": email, "username": "jane", "full_name": "Jane Doe"}
def lookup_user(user_id):
return {"id": user_id, "email": "[email protected]", "username": "jane",
"full_name": "Jane Doe", "avatar_url": "https://cdn.example.com/jane.png"}
LOGIN_FORM = """
<form method=post action=/login>
<input name=email type=email placeholder=Email required>
<input name=password type=password placeholder=Password required>
<button>Sign in</button>
</form>
"""
if __name__ == "__main__":
app.run(port=5000, debug=True)
Then in Discourse admin → Login:
enable_discourse_connect= ONdiscourse_connect_url=https://auth.example.com/ssodiscourse_connect_secret= same value asDISCOURSE_SECRETenv var (must be ≥ 10 chars)
Provider Implementation: Express (Node.js)
The same flow in Node, using only the standard library plus express and express-session:
const express = require("express");
const session = require("express-session");
const crypto = require("crypto");
const querystring = require("querystring");
const DISCOURSE_SECRET = process.env.DISCOURSE_SECRET;
const DISCOURSE_URL = process.env.DISCOURSE_URL;
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: true,
cookie: { secure: true, httpOnly: true, sameSite: "lax" },
}));
function sign(b64) {
return crypto.createHmac("sha256", DISCOURSE_SECRET).update(b64).digest("hex");
}
app.get("/sso", (req, res) => {
const { sso, sig } = req.query;
if (!sso || !sig) return res.sendStatus(400);
// 1. Verify inbound HMAC.
const expected = sign(sso);
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return res.status(403).send("Bad signature on inbound request");
}
// 2. Decode payload — extract nonce + return URL.
const decoded = Buffer.from(sso, "base64").toString("utf-8");
const fields = querystring.parse(decoded);
req.session.ssoNonce = fields.nonce;
req.session.ssoReturnUrl = fields.return_sso_url;
// 3. If already logged in, redirect immediately.
if (req.session.userId) return finishSso(req, res, req.session.userId);
res.send(LOGIN_FORM);
});
app.post("/login", async (req, res) => {
const user = await authenticateUser(req.body.email, req.body.password);
if (!user) return res.sendStatus(401);
req.session.userId = user.id;
return finishSso(req, res, user.id);
});
async function finishSso(req, res, userId) {
const user = await lookupUser(userId);
const payload = {
nonce: req.session.ssoNonce,
email: user.email,
external_id: String(user.id),
username: user.username,
name: user.fullName,
avatar_url: user.avatarUrl || "",
};
const encoded = querystring.stringify(payload);
const ssoB64 = Buffer.from(encoded, "utf-8").toString("base64");
const sig = sign(ssoB64);
res.redirect(
`${req.session.ssoReturnUrl}?sso=${encodeURIComponent(ssoB64)}&sig=${sig}`
);
}
// Stubs
async function authenticateUser(email, password) {
return { id: 42, email, username: "jane", fullName: "Jane Doe" };
}
async function lookupUser(id) {
return { id, email: "[email protected]", username: "jane",
fullName: "Jane Doe", avatarUrl: "https://cdn.example.com/jane.png" };
}
const LOGIN_FORM = `
<form method=post action=/login>
<input name=email type=email placeholder=Email required>
<input name=password type=password placeholder=Password required>
<button>Sign in</button>
</form>`;
app.listen(5000);
Two production gotchas with the Node version:
- Use
crypto.timingSafeEqual, not===, for HMAC comparison. String compare leaks timing info that lets attackers brute-force the signature byte-by-byte. The Python version above useshmac.compare_digestfor the same reason. - The buffers must be the same length. If you accidentally pass a Buffer with different length to
timingSafeEqual, Node throws synchronously. Wrap in try/catch or pre-check lengths.
Common Errors and Fixes
Field-by-field debug map for the four errors you'll actually see:
| Error message | Cause | Fix |
|---|---|---|
Bad signature for payload |
Most likely: you signed the URL-encoded payload instead of the base64-encoded payload. Or the secret differs between provider and Discourse. | Compute hmac.new(secret, base64_string, sha256).hexdigest(). The HMAC input is the base64 string, not the decoded text. Verify discourse_connect_secret in admin matches your env var byte-for-byte (no trailing newlines). |
CSRF detected / Nonce mismatch |
The user took longer than 10 minutes to complete login at the provider, so the nonce in Redis expired. Or your provider is generating a new nonce instead of echoing the one it received. | Echo nonce from inbound payload back unchanged. If your login flow includes email verification, raise discourse_connect_nonce_minutes in admin (default 10) or shorten the flow. |
Email is already taken |
A Discourse user with that email already exists, but with a different external_id than your provider sends. Common during migrations: existing forum users sign up with email, then you bolt on SSO afterward. |
Either include the missing external_id in the existing Discourse user via the API (PUT /admin/users/:id/sync_sso), or send the require_activation=true flag and let the user verify ownership. |
User has no email / Email is invalid |
Field-name typo in the payload (e.g. emai, e-mail, userEmail) — Discourse silently ignores unknown fields, so missing email looks like the user has none. |
Use exactly the field names from the table above. Run print(payload) before signing — the field name is email, lowercase, no underscore. |
403 Forbidden at /session/sso_login |
Either the inbound sig is wrong (provider bug), or Discourse has enable_discourse_connect turned off, or the user is logged out and Discourse rejects the unsolicited callback. |
Confirm the user actually started at /session/sso on Discourse. Don't link directly to your provider's /sso endpoint — let Discourse generate the nonce. |
Discourse SSO error: Sso disabled |
Toggle was reset (admin flipped it back) or the secret changed. | Re-enable enable_discourse_connect; confirm the secret is at least 10 characters. |
The five-minute debug routine
- On the provider, log the inbound
ssoandsigverbatim and the computed expected sig. Compare them character by character. A single trailing newline in your secret env var corrupts every HMAC. - Base64-decode the inbound
ssoon the command line:echo "<sso_value>" | base64 -d. You should seenonce=...&return_sso_url=.... If not, the value got URL-encoded twice somewhere. - On the response side, before you redirect, print the response payload, the base64, and the sig. Verify Discourse-side using the same
echo | base64 -dtrick on the value you're about to ship. - In a Discourse rails console:
DiscourseConnect.parse(sso, sig).lookup_or_create_user(...)— this is what Discourse runs internally, and it gives a clearer error than the web UI. - If Discourse logs are accessible:
tail -f /var/discourse/shared/standalone/log/rails/production.log. The exact failure line is logged with the constructed expected sig.
Discourse as IdP — The Reverse Direction
If your forum predates the rest of your product, you can flip the script: Discourse becomes the identity provider, and a satellite app (knowledge base, internal tool, marketplace) consumes Discourse identities.
In Discourse admin → Login:
enable_discourse_connect_provider= ONdiscourse_connect_provider_secrets: one entry per consumer, formathttps://kb.example.com|secret_for_kb, one per line.
The flow is the same protocol in mirror image. Your consumer app generates the nonce, Discourse authenticates the user, Discourse signs the response. Skeleton consumer code in Python:
import secrets, base64, hmac, hashlib
from urllib.parse import urlencode, parse_qs
DISCOURSE_URL = "https://forum.example.com"
SECRET = b"secret_for_kb"
def start_login(return_url):
nonce = secrets.token_hex(16)
payload = urlencode({"nonce": nonce, "return_sso_url": return_url}).encode()
sso = base64.b64encode(payload)
sig = hmac.new(SECRET, sso, hashlib.sha256).hexdigest()
# Save nonce in your session for later verification
return f"{DISCOURSE_URL}/session/sso_provider?sso={sso.decode()}&sig={sig}", nonce
def verify_callback(sso, sig, expected_nonce):
if hmac.new(SECRET, sso.encode(), hashlib.sha256).hexdigest() != sig:
raise ValueError("Bad signature")
fields = {k: v[0] for k, v in parse_qs(base64.b64decode(sso).decode()).items()}
if fields["nonce"] != expected_nonce:
raise ValueError("Nonce mismatch")
return fields # {email, external_id, username, ...}
The endpoint is /session/sso_provider on Discourse (not /session/sso). The response payload contains the same fields as before, plus Discourse-specific extras like admin, moderator, groups, and profile_background_url.
Sync vs JIT Provisioning: When to Use override_X
By default Discourse uses Just-In-Time provisioning: on first login, fields like username, name, and avatar_url are taken from your payload, but on subsequent logins the user can edit them inside Discourse and your provider's values are ignored. This is usually what you want.
Set override_X=true when:
override_username— your app is the source of truth for usernames (e.g. employee directory). Otherwise leave off so users can pick a forum-specific handle.override_name— names change on the provider side (marriage, legal change) and you want them mirrored.override_avatar— you have a Gravatar-like centralized avatar. Note: every login fetches the URL, which adds latency.override_email— only when your provider is authoritative for email. Discourse will update the user's email and require re-verification.
override_username=true breaks every mention and link to the user's old name. If you turn this on, also enable username_change_period=0 so users can't fight the sync.
Group Sync
The add_groups and remove_groups fields let you synchronize Discourse group membership from your app. Send the comma-separated list of group names (not IDs):
# In your provider response:
payload = {
"nonce": nonce,
"email": user.email,
"external_id": str(user.id),
"add_groups": "customers,early_access",
"remove_groups": "trial",
}
This is how teams gate Discourse categories by their own subscription tiers — your billing system tracks who's a Pro customer, the SSO payload says add_groups=pro_customers, and Discourse grants access to the Pro-only category.
Logout Flow
Single sign-out is opt-in. To log a user out from Discourse when they log out of your app:
- In admin, set
logout_redirectto your app's logout URL. - When your app logs the user out, also call the Discourse Admin API:
POST /admin/users/:id/log_out(requires API key). - Alternatively, send
logout=truein an SSO payload — Discourse will log the user out and not re-authenticate.
When to Hand It Off to AI
HMAC bugs are the worst kind: silent, deterministic, and the error message ("Bad signature for payload") only tells you the comparison failed, not why. Half the time it's a base64-vs-raw mistake; the other half it's a one-character secret diff between admin and env var.
SimpleReview diffs your provider implementation against the canonical Ruby SingleSignOn class, runs an HMAC against a known-good fixture, and prepares a fix with the fix. Useful when you're staring at the bad-signature screen and have already triple-checked the secret.
Stop Debugging HMAC by Eyeball
Click the "Bad signature for payload" error — SimpleReview reads your provider, finds the encoding mismatch, and prepares a fix with the fix.
Install SimpleReview Chrome Extension →Want a Discourse plugin with built-in SSO debugging? See: Develop a Discourse plugin →
Frequently Asked Questions
sig. Discourse re-runs the same HMAC on the received sso value and compares.discourse_connect_nonce_minutes.override_username=true, override_name=true, or override_avatar=true in the response payload. By default Discourse only sets these on first login (JIT) and lets users edit them.discourse_connect_provider_secrets with one entry per consumer (URL|secret). Each consumer signs requests with its own secret.Related Discourse Guides
Sources
- meta.discourse.org — DiscourseConnect: Official Single Sign On (rebrand thread)
- github.com/discourse — lib/single_sign_on.rb (canonical signing implementation)
- meta.discourse.org — Setting up DiscourseConnect as an SSO Provider (Discourse-as-IdP)
- meta.discourse.org — Support category: SSO troubleshooting threads
- meta.discourse.org — sync_sso admin API for backfilling external_id