Single Sign-On (SSO) Integration with Discourse Using Supabase Auth and Deno Edge Functions
The repository implements Discourse SSO through a stateless Deno edge function located in supabase/functions/sso/index.ts, which verifies HMAC-SHA256 signatures, authenticates users via Supabase Auth, and returns a signed payload to complete the Discourse login flow.
The tcg-pocket-collection-tracker project demonstrates a production-ready pattern for integrating Discourse forums with Supabase authentication. By leveraging Supabase Edge Runtime and Deno, the solution eliminates server maintenance while maintaining cryptographic security through shared secrets and signature verification.
How the SSO Flow Works
The edge function orchestrates a 10-step handshake between Discourse and Supabase:
- CORS pre-flight – Handles
OPTIONSrequests to allow cross-origin communication between the frontend and edge function. - Payload extraction – Parses the JSON body to extract the Base64-encoded
ssopayload and itssigsignature. - Signature verification – Recomputes the HMAC-SHA256 of the
ssostring using theDISCOURSE_CONNECTsecret; rejects mismatches. - Supabase initialization – Creates a client using
SUPABASE_URLandSUPABASE_ANON_KEY. - User authentication – Validates the Bearer token from the
Authorizationheader viasupabase.auth.getUser(). - Account lookup – Optionally queries the
accountstable for custom usernames. - Payload decoding – Base64-decodes the
ssostring and parses it to extractreturn_sso_urlandnonce. - Response building – Constructs a new payload containing the user's email, external ID, optional username, and
suppress_welcome_messageflag. - Response signing – URL-encodes and Base64-encodes the payload, then generates a new HMAC-SHA256 signature.
- Redirect generation – Returns a JSON object with
redirectUrlcontaining the signedssoandsigparameters appended to Discourse's return URL.
Deno Edge Function Implementation
CORS and Request Handling
The function begins by handling cross-origin requests and parsing the incoming Discourse payload:
// supabase/functions/sso/index.ts
if (req.method === 'OPTIONS') {
return new Response('ok', {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
},
});
}
const { sso, sig } = await req.json();
Signature Verification with Discourse Connect Secret
Security relies on a shared secret (DISCOURSE_CONNECT) to verify the authenticity of incoming requests:
import { createHmac } from 'node:crypto';
const discourseConnectSecret = Deno.env.get('DISCOURSE_CONNECT');
if (!discourseConnectSecret) {
throw new Error('Missing DISCOURSE_CONNECT secret');
}
const computedSig = createHmac('sha256', discourseConnectSecret)
.update(sso)
.digest('hex');
if (computedSig !== sig) {
return new Response(JSON.stringify({ error: 'Invalid signature' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
Supabase Auth Integration
The function validates the user's JWT before proceeding:
import { createClient } from 'jsr:@supabase/supabase-js@2';
const supabaseClient = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? ''
);
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Missing Authorization header' }), {
status: 401,
});
}
const token = authHeader.replace('Bearer ', '');
const { data: { user }, error: userError } = await supabaseClient.auth.getUser(token);
if (userError || !user) {
return new Response(JSON.stringify({ error: 'Invalid user token' }), {
status: 401,
});
}
Payload Construction and Response
After fetching optional account data, the function constructs and signs the return payload:
// Fetch optional custom username
const { data: account } = await supabaseClient
.from('accounts')
.select()
.eq('user_id', user.id)
.single();
// Decode incoming Discourse payload
const ssoDecoded = Buffer.from(sso, 'base64').toString('utf-8');
const ssoParams = new URLSearchParams(ssoDecoded);
const returnUrl = ssoParams.get('return_sso_url');
const nonce = ssoParams.get('nonce');
// Build response payload
const payload = {
nonce,
email: user.email,
external_id: user.email,
username: account?.username,
suppress_welcome_message: true,
};
// Encode and sign
const urlEncodedPayload = new URLSearchParams(payload).toString();
const payloadBase64 = Buffer.from(urlEncodedPayload).toString('base64');
const signature = createHmac('sha256', discourseConnectSecret)
.update(payloadBase64)
.digest('hex');
return new Response(
JSON.stringify({ redirectUrl: `${returnUrl}?sso=${payloadBase64}&sig=${signature}` }),
{
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
}
);
Environment Configuration
The edge function requires three environment variables configured in your Supabase project:
| Variable | Purpose | Source |
|---|---|---|
DISCOURSE_CONNECT |
Shared secret for HMAC-SHA256 signature verification between Discourse and the edge function. | Discourse Admin → Settings → Login → discourse connect secret |
SUPABASE_URL |
Project URL used to initialize the Supabase client. | Supabase Project Settings → API |
SUPABASE_ANON_KEY |
Public anonymous key for Supabase client initialization. | Supabase Project Settings → API |
These variables are accessed at runtime via Deno.env.get() as shown in the implementation.
Database Schema for Custom Usernames
The function optionally queries an accounts table to retrieve custom usernames. The expected schema:
create table accounts (
id uuid primary key default uuid_generate_v4(),
user_id uuid references auth.users(id) not null,
username text not null unique
);
When a matching row exists for the authenticated user, the username field is included in the SSO payload sent to Discourse.
Summary
- Location: The SSO implementation resides in
supabase/functions/sso/index.tsas a Deno edge function. - Security: Uses HMAC-SHA256 signature verification with a shared
DISCOURSE_CONNECTsecret to validate incoming Discourse requests and sign responses. - Authentication: Leverages Supabase Auth by validating Bearer tokens via
supabase.auth.getUser(). - Data enrichment: Optionally queries the
accountstable to inject custom usernames into the Discourse payload. - Response: Returns a JSON object containing a
redirectUrlwith signedssoandsigparameters that complete the Discourse login flow.
Frequently Asked Questions
How does the edge function verify that requests actually come from Discourse?
The function recomputes the HMAC-SHA256 hash of the incoming sso payload using the DISCOURSE_CONNECT environment variable as the secret. It compares this computed signature against the sig parameter provided by Discourse. If they do not match, the function returns a 403 error, rejecting the request as unauthorized.
Can I customize the username sent to Discourse instead of using the email address?
Yes. The function queries an optional accounts table using supabaseClient.from('accounts').select().single(). If a row exists matching the authenticated user's ID, the function includes account.username in the SSO payload. You must create this table and populate it with usernames mapped to user IDs.
What happens if the Supabase Auth token is invalid or missing?
The function checks for the Authorization header and extracts the Bearer token. It then calls supabaseClient.auth.getUser(token). If the token is missing, malformed, or invalid, the function returns a 401 response with an error message, preventing unauthorized access to the SSO endpoint.
Is this implementation stateless, and why does that matter?
Yes, the implementation is completely stateless. The Deno edge function does not maintain session state between requests; all necessary context (user identity, Discourse nonce, signatures) is passed in the request payload or derived from Supabase Auth. This enables horizontal scaling, reduces infrastructure complexity, and ensures that the function can run on Supabase Edge Runtime with minimal cold-start latency.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →