DiscourseConnect HMAC validation fails with WordPress despite matching callback parameters

Hi,

I’m setting up DiscourseConnect between a WordPress site and a hosted Discourse instance and have reached a persistent HMAC signature validation failure.

Setup

  • WordPress is the authentication/provider side.
  • Discourse is using DiscourseConnect.
  • WordPress receives the DiscourseConnect request and returns the callback containing sso and sig.
  • The callback reaches Discourse, but Discourse rejects it because the HMAC signature does not validate.

We have done fairly extensive debugging and have narrowed the problem down considerably.

What we have confirmed

  • wp_unslash() does not alter the callback values.
  • The EA/WordPress validation inputs match the parameters reconstructed from WordPress’s observed query string.
  • The expected callback code is definitely executing.
  • The deployed source files match our build manifest.
  • We have checked for wrong-file and duplicate-definition issues.
  • PHP-FPM has been restarted after the latest corrections, so this is not an old PHP process/opcache state.
  • A completely fresh DiscourseConnect request after those changes still fails HMAC validation.

In other words, the current failure is reproducible on a fresh request.

What we have not yet been able to prove

  1. That the effective secret being used by Discourse at runtime is byte-for-byte identical to the secret being used by WordPress.
  2. That the sso payload is not being changed somewhere before the point at which WordPress observes the request.

At this stage we don’t want to keep changing settings or code blindly.

Question

For current Discourse/DiscourseConnect, what is the best way to determine exactly which payload and secret Discourse is using when it calculates the expected HMAC?

Is there a recommended debugging/logging method that would allow us to compare the Discourse-side HMAC input with the WordPress-side input without exposing the actual secret publicly?

If there are any known issues involving WordPress/PHP handling, URL encoding, Base64 payloads, reverse proxies, or hosted Discourse that could produce this situation, I’d also appreciate pointers.

I can provide sanitized request/callback values, relevant WordPress code, and logs if needed.

Thanks.

So what are you using on Wordpress side of things? The WP-Discourse plugin? If not, can you post your code?

Hi Richard. WP-Discourse is installed, but its DiscourseConnect provider and login-sync functions are disabled.

We’re using a small custom WordPress plugin as the DiscourseConnect provider. It receives the sso and sig parameters from Discourse, verifies the incoming HMAC using the shared secret, then builds/signs the response back to Discourse.

The failure is currently occurring on the incoming request from Discourse to WordPress — our fresh diagnostic shows the HMAC recomputation does not match the sig received from Discourse.

Happy to post the relevant PHP callback/validation code. I’ll remove configuration values/secrets before posting it.

So that is the other way around, yes?

The obvious route would be to use the WP-Discourse plugin, but you probably have good reasons not to.
Yes, please post the code.

Yes — you’re correct. My wording in the original post was backwards.

The current failure is Discourse → WordPress.

Discourse generates the DiscourseConnect request containing sso and sig. WordPress receives it, and our custom EA provider attempts to verify the signature. That incoming HMAC verification fails, so WordPress stops there. It does not get as far as returning the authenticated user payload to Discourse.

WP-Discourse is installed, but its DiscourseConnect provider and user-login sync functions are disabled. We’re currently using the custom EA provider because we want the EA account/profile layer to remain under our control.

I’ll post the relevant callback/config/HMAC-validation code below with all secrets and private configuration removed.

This is the sanitised normal incoming DiscourseConnect validation path (Discourse → WordPress) in EA’s custom WordPress provider.

<?php
// Configuration constants are defined elsewhere; their private values are omitted.

final class EA_Discourse_Connect {
	public function __construct() {
		add_action( 'admin_post_ea_discourse_connect', array( $this, 'connect' ) );
		add_action( 'admin_post_nopriv_ea_discourse_connect', array( $this, 'connect' ) );
	}

	public static function enabled() {
		return defined( 'EA_DISCOURSE_CONNECT_ENABLED' ) && true === EA_DISCOURSE_CONNECT_ENABLED;
	}

	public static function config() {
		$url = defined( 'EA_DISCOURSE_URL' ) ? EA_DISCOURSE_URL : '';
		$secret = defined( 'EA_DISCOURSE_CONNECT_SECRET' ) ? EA_DISCOURSE_CONNECT_SECRET : '';

		if ( ! is_string( $url ) || ! preg_match( '~\Ahttps://[a-z0-9.-]+(?::[0-9]+)?(?:/[a-z0-9_-]+)*/?\z~i', $url ) ) {
			$url = '';
		}

		return array(
			'url'    => rtrim( $url, '/' ),
			'secret' => is_string( $secret ) ? $secret : '',
		);
	}

	public static function ready() {
		$c = self::config();

		return self::enabled()
			&& $c['url']
			&& strlen( $c['secret'] ) >= 32
			&& 'https' === wp_parse_url( home_url(), PHP_URL_SCHEME );
	}

	private function fail( $message, $code = 400 ) {
		wp_die(
			esc_html( $message ),
			'EA community sign-in',
			array( 'response' => $code )
		);
	}

	public static function validate( $payload, $signature, $config ) {
		if (
			! is_string( $payload )
			|| ! is_string( $signature )
			|| strlen( $payload ) > 8192
			|| ! preg_match( '/\A[a-f0-9]{64}\z/', $signature )
			|| ! hash_equals(
				hash_hmac( 'sha256', $payload, $config['secret'] ),
				$signature
			)
		) {
			return false;
		}

		$decoded = base64_decode( $payload, true );

		if ( false === $decoded ) {
			return false;
		}

		parse_str( $decoded, $params );

		if (
			empty( $params['nonce'] )
			|| ! is_string( $params['nonce'] )
			|| strlen( $params['nonce'] ) > 256
			|| ! isset( $params['return_sso_url'] )
			|| $config['url'] . '/session/sso_login' !== $params['return_sso_url']
		) {
			return false;
		}

		return $params['nonce'];
	}

	public function connect() {
		// Response-header setup omitted.

		if ( ! self::enabled() ) {
			$this->fail( 'Community sign-in is disabled.', 503 );
		}

		if ( ! self::ready() ) {
			$this->fail( 'Community sign-in is not configured.', 503 );
		}

		$c = self::config();

		$payload = isset( $_GET['sso'] )
			? wp_unslash( $_GET['sso'] )
			: null;

		$sig = isset( $_GET['sig'] )
			? wp_unslash( $_GET['sig'] )
			: null;

		$nonce = self::validate( $payload, $sig, $c );

		if ( false === $nonce ) {
			$this->fail(
				'Invalid community sign-in request. Start again from the community.'
			);
		}

		// Subsequent WordPress login and authenticated response code omitted.
	}
}

// Instantiated by the plugin bootstrap:
new EA_Discourse_Connect();

You should urldecode() the parameters, not wp_unslash()

Thanks Richard. I checked that point against PHP/WordPress request handling and the current Discourse signing flow.

In our callback we read from $_GET, so PHP has already URL-decoded the query parameters before EA receives them. wp_unslash() is only reversing WordPress’s request slashing; it is not doing URL decoding.

Applying urldecode() again at that point would double-decode the value and can turn a Base64 + into a space, which would itself break the HMAC.

I’ve also confirmed our fresh diagnostic still fails the HMAC after the WordPress and Discourse secrets were corrected to match.

So I don’t think replacing wp_unslash() with urldecode() is the right fix for this particular callback path. I’m continuing to trace where the signed payload may be diverging before it reaches PHP.