# Single Sign-On (SSO) Integration with Discourse Using Supabase Auth and Deno Edge Functions

> Implement Discourse SSO with Supabase Auth and Deno Edge Functions. This guide details stateless integration, signature verification, and user authentication for a seamless login flow.

- Repository: [Marcel Panse/tcg-pocket-collection-tracker](https://github.com/marcelpanse/tcg-pocket-collection-tracker)
- Tags: how-to-guide
- Published: 2026-03-06

---

**The repository implements Discourse SSO through a stateless Deno edge function located in [`supabase/functions/sso/index.ts`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/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:

```typescript
// 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:

```typescript
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:

```typescript
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:

```typescript
// 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:

```sql
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`](https://github.com/marcelpanse/tcg-pocket-collection-tracker/blob/main/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.