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:

  1. CORS pre-flight – Handles OPTIONS requests to allow cross-origin communication between the frontend and edge function.
  2. Payload extraction – Parses the JSON body to extract the Base64-encoded sso payload and its sig signature.
  3. Signature verification – Recomputes the HMAC-SHA256 of the sso string using the DISCOURSE_CONNECT secret; rejects mismatches.
  4. Supabase initialization – Creates a client using SUPABASE_URL and SUPABASE_ANON_KEY.
  5. User authentication – Validates the Bearer token from the Authorization header via supabase.auth.getUser().
  6. Account lookup – Optionally queries the accounts table for custom usernames.
  7. Payload decoding – Base64-decodes the sso string and parses it to extract return_sso_url and nonce.
  8. Response building – Constructs a new payload containing the user's email, external ID, optional username, and suppress_welcome_message flag.
  9. Response signing – URL-encodes and Base64-encodes the payload, then generates a new HMAC-SHA256 signature.
  10. Redirect generation – Returns a JSON object with redirectUrl containing the signed sso and sig parameters 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.ts as a Deno edge function.
  • Security: Uses HMAC-SHA256 signature verification with a shared DISCOURSE_CONNECT secret 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 accounts table to inject custom usernames into the Discourse payload.
  • Response: Returns a JSON object containing a redirectUrl with signed sso and sig parameters 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →