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.

forum.example.com/session/sso_login
SimpleReview extension
⚠ Bad signature for payload
Discourse received sso=bm9uY2U9YWJj... and computed HMAC-SHA256, but the sig did not match. Verify discourse_connect_secret matches your provider and that you signed the base64 string, not the decoded payload.
DiscourseConnect · Provider: auth.example.com · sig length: 64 hex chars
LatestCategoriesYou
✓ SSO login successful · external_id=42
Welcome back, [email protected]
Comment×
DiscourseConnect signature verification fails — fix the HMAC encoding|
Fix it ✓ Done
waiting for selection…
Detected
ErrorBad signature
Filesso_provider.py
Fix plan
Sign base64(payload) not raw payload · use hmac.new(secret, b64, sha256)
Result
HMAC matches. /session/sso_login redirect succeeds.
✓ Fix ready
fix: sign base64 payload in DiscourseConnect
sso_provider.py · 3 lines
Click SimpleReview → select "Bad signature" → Fix it → HMAC encoding patched, login succeeds
Stuck on "Bad signature for payload"? → SimpleReview reads your provider code, compares the HMAC against a reference implementation, and opens a one-line site fix — usually the fix is "sign the base64 string, not the decoded payload".

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 sso and sig ride 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:

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.

Why both names persist: Discourse never broke the wire format. Old plugins and providers still work without code changes. Only the human-facing names changed, so the community settled into using both terms interchangeably — "DiscourseConnect" in new docs, "Discourse SSO" in conversation and search.

Two Directions: Discourse as RP vs Discourse as IdP

Before writing any code, decide who owns the user accounts. DiscourseConnect supports both roles:

ModeWho owns identitiesUse whenAdmin setting
Discourse as RP (Relying Party)Your appYou already have a SaaS / WordPress / Rails app and want to add a forum that uses your existing accountsenable_discourse_connect + discourse_connect_url + discourse_connect_secret
Discourse as IdP (Identity Provider)DiscourseYour forum predates other tools; you want satellite apps (knowledge base, marketplace, voting tool) to share Discourse accountsenable_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

FieldDirectionRequiredNotes
nonceBothYesDiscourse generates on /session/sso, provider must echo back unchanged. Single-use, 10-min TTL.
return_sso_urlDiscourse → providerYesWhere the provider redirects after auth. Always /session/sso_login.
emailProvider → DiscourseYesVerified email. Discourse trusts it as confirmed.
external_idProvider → DiscourseYesStable user ID in your system. Never reuse across users.
usernameProvider → DiscourseNoSuggested username. Discourse normalizes (lowercase, strip).
nameProvider → DiscourseNoDisplay name.
avatar_urlProvider → DiscourseNoHTTPS URL to a square image.
bioProvider → DiscourseNoUser bio / about-me text.
adminProvider → DiscourseNotrue/false. Promotes the user.
moderatorProvider → DiscourseNotrue/false.
add_groupsProvider → DiscourseNoComma-separated group names to add the user to.
remove_groupsProvider → DiscourseNoComma-separated group names to remove.
require_activationProvider → DiscourseNotrue if email is unverified — Discourse will send activation.
suppress_welcome_messageProvider → DiscourseNotrue to skip the new-user welcome PM.
override_usernameProvider → DiscourseNotrue to update on every login (sync).
override_nameProvider → DiscourseNotrue to overwrite display name.
override_avatarProvider → DiscourseNotrue to refetch avatar.
logoutProvider → DiscourseNotrue 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:

User Discourse Your Provider (auth.example.com) | | | | click "Log In" | | |-------------------->| | | | generate nonce, store in Redis | | | payload = "nonce=ABC&return_sso_url= | | | https://forum/session/ | | | sso_login" | | | sso = base64(payload) | | | sig = hmac_sha256(secret, sso).hex() | | | | | 302 Redirect to | | | auth.example.com/ | | | sso?sso=...&sig=...| | |<--------------------| | | | | GET /sso?sso=...&sig=... | |------------------------------------------------------------->| | | | verify sig (HMAC match) | | decode payload, extract nonce | | show login form (or use session) | | | | user enters credentials | |------------------------------------------------------------->| | | | authenticate user | | build response payload: | | nonce=ABC (echo back!) | | [email protected] | | external_id=42 | | username=jane | | sso = base64(response_payload) | | sig = hmac_sha256(secret, sso).hex()| | | | 302 Redirect to forum.example.com/session/sso_login | | ?sso=...&sig=... | |<-------------------------------------------------------------| | | | GET /session/sso_login?sso=...&sig=... | |-------------------->| | | | verify sig | | | base64-decode sso | | | check nonce matches Redis entry | | | upsert user by external_id | | | set session cookie | | 302 to / | | |<--------------------| | | | | | GET / (logged in) | | |-------------------->| | | HTML home page | | |<--------------------| |

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:

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:

Common Errors and Fixes

Field-by-field debug map for the four errors you'll actually see:

Error messageCauseFix
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

  1. On the provider, log the inbound sso and sig verbatim and the computed expected sig. Compare them character by character. A single trailing newline in your secret env var corrupts every HMAC.
  2. Base64-decode the inbound sso on the command line: echo "<sso_value>" | base64 -d. You should see nonce=...&return_sso_url=.... If not, the value got URL-encoded twice somewhere.
  3. On the response side, before you redirect, print the response payload, the base64, and the sig. Verify Discourse-side using the same echo | base64 -d trick on the value you're about to ship.
  4. 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.
  5. 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:

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:

Don't override username casually. Discourse usernames appear in @-mentions and post URLs. Forcing a rename via 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:

  1. In admin, set logout_redirect to your app's logout URL.
  2. When your app logs the user out, also call the Discourse Admin API: POST /admin/users/:id/log_out (requires API key).
  3. Alternatively, send logout=true in 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

Is DiscourseConnect the same as Discourse SSO?
Yes. DiscourseConnect is the new (2021+) name for the protocol previously called "Discourse SSO". The wire format, HMAC-SHA256 signing, and nonce flow are identical. Older blog posts and the Ruby class name still use "SSO". Treat them as synonyms.
How does the DiscourseConnect signing work?
Build a URL-encoded payload, base64-encode it, then compute HMAC-SHA256 of the base64 string using the shared secret as the key. Hex digest of that HMAC is the sig. Discourse re-runs the same HMAC on the received sso value and compares.
What causes "Bad signature for payload"?
Five common causes: (1) secret mismatch between provider and Discourse, (2) you signed the decoded payload instead of the base64 string, (3) base64 padding was URL-encoded incorrectly, (4) you used SHA1/MD5 instead of HMAC-SHA256, or (5) you treated the secret as hex/base64 when it's a plain UTF-8 string.
What is the difference between Discourse as RP and Discourse as IdP?
RP (Relying Party): your existing app owns identities, Discourse delegates login to it. IdP (Identity Provider): Discourse owns accounts, satellite apps consume them. Most teams want RP. IdP is for forums that predate the rest of the product.
Why am I getting a nonce mismatch error?
Discourse stores each generated nonce in Redis with a 10-minute TTL. If a user takes longer than 10 minutes to authenticate at your provider, the nonce expires. Either shorten the login flow or raise discourse_connect_nonce_minutes.
How do I sync usernames and avatars from my provider?
Set 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.
Can I use DiscourseConnect with multiple consumer apps?
Yes, when Discourse is the IdP. Set discourse_connect_provider_secrets with one entry per consumer (URL|secret). Each consumer signs requests with its own secret.

Related Discourse Guides

Sources