A secure sign-up bridge between WordPress and a C# platform
By Rolando Escobar, WordPress developer for agencies · · 5 min read
When WordPress handles sign-up but another system owns the account, the new user’s password has to travel from one to the other. That hop is the riskiest moment in the whole flow, and a plain POST with the password in the body isn’t enough.
GLEbill, a legal e-billing platform from Global Legal Ebilling, works this way. The platform is written in C#, and WordPress is its sign-up front end: customers register and manage their profile in WordPress, WordPress sends each new user and their credentials, encrypted, to the platform’s API endpoints through a custom REST integration, the platform creates the account, and the same login works on both systems. The code below is a tutorial version of that kind of bridge, not the project’s code. At the end I’ll cover when you shouldn’t sync passwords at all.
The flow, step by step
- The visitor submits your sign-up form over HTTPS.
- WordPress validates the input and creates the user. It stores only its own password hash.
- While the plaintext password is still in memory, WordPress builds a JSON payload and encrypts it with AES-256-GCM.
- WordPress signs the request with an HMAC over a timestamp, a request ID and the encrypted body, then posts it to the platform over TLS.
- The platform checks the signature and the timestamp, decrypts the payload, creates the account and hashes the password with its own hasher.
- WordPress records whether the sync worked. The plaintext password is never written to the database, a log file or an email.
Catch the password at the right moment
The user_register hook fires after WordPress inserts a user and receives the raw $userdata array. With core’s default registration, though, the password in that array is a random one WordPress generated; the user chooses the real one later through the “set your password” link. A custom sign-up handler is simpler, because you know exactly when the plaintext exists:
add_action( 'admin_post_nopriv_rw_signup', 'rw_handle_signup' );
function rw_handle_signup(): void {
check_admin_referer( 'rw_signup', 'rw_signup_nonce' );
$back = home_url( '/sign-up/' );
$email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) );
$password = (string) wp_unslash( $_POST['password'] ?? '' ); // Never sanitize or log a password.
if ( ! is_email( $email ) || strlen( $password ) < 12 ) {
wp_safe_redirect( add_query_arg( 'signup', 'invalid', $back ) );
exit;
}
$user_id = wp_insert_user(
array(
'user_login' => $email,
'user_email' => $email,
'user_pass' => $password,
'first_name' => sanitize_text_field( wp_unslash( $_POST['first_name'] ?? '' ) ),
'last_name' => sanitize_text_field( wp_unslash( $_POST['last_name'] ?? '' ) ),
'role' => 'subscriber',
)
);
if ( is_wp_error( $user_id ) ) {
wp_safe_redirect( add_query_arg( 'signup', 'error', $back ) );
exit;
}
$synced = rw_platform_create_account( $user_id, $password );
update_user_meta( $user_id, 'rw_platform_sync', $synced ? 'ok' : 'failed' );
wp_safe_redirect( home_url( $synced ? '/welcome/' : '/welcome/?setup=pending' ) );
exit;
}
The password goes through wp_unslash() only. Running it through sanitize_text_field() would silently change passwords that contain characters like < or extra spaces. Password changes need the same treatment later: handle them in your own profile form, and use the password_reset hook for resets done through core.
Encrypt and sign the payload
TLS already encrypts the connection, and you still need it. Encrypting the body as well helps because TLS ends at the first proxy, load balancer or API gateway, and any of them might log request bodies. An encrypted payload stays unreadable in those logs. The HMAC signature proves the request came from your WordPress site and is fresh.
AES-256-GCM is a good fit here because .NET supports it natively through AesGcm. libsodium’s sodium_crypto_secretbox() is just as sound, but the C# side would need a libsodium binding. Generate each key once with php -r "echo base64_encode(random_bytes(32));" and keep the encryption key and the signing key separate:
// wp-config.php: RW_PLATFORM_ENC_KEY and RW_PLATFORM_HMAC_KEY (base64, 32 bytes each), RW_PLATFORM_API_URL.
function rw_platform_build_request( array $payload ): array {
$key = base64_decode( RW_PLATFORM_ENC_KEY, true );
if ( false === $key || 32 !== strlen( $key ) ) {
throw new RuntimeException( 'RW_PLATFORM_ENC_KEY must be 32 bytes, base64-encoded.' );
}
$iv = random_bytes( 12 ); // A fresh 96-bit nonce for every message.
$tag = '';
$ciphertext = openssl_encrypt( wp_json_encode( $payload ), 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16 );
if ( false === $ciphertext ) {
throw new RuntimeException( 'Encryption failed.' );
}
$body = wp_json_encode(
array(
'v' => 1, // Key version, so keys can be rotated later.
'iv' => base64_encode( $iv ),
'tag' => base64_encode( $tag ),
'data' => base64_encode( $ciphertext ),
)
);
$timestamp = (string) time();
$request_id = wp_generate_uuid4();
$signature = hash_hmac( 'sha256', $timestamp . '.' . $request_id . '.' . $body, base64_decode( RW_PLATFORM_HMAC_KEY, true ) );
return array(
'timeout' => 5,
'headers' => array(
'Content-Type' => 'application/json',
'X-Timestamp' => $timestamp,
'X-Request-Id' => $request_id,
'X-Signature' => $signature,
),
'body' => $body,
);
}
GCM already detects tampering with the ciphertext. The separate HMAC also covers the timestamp and the request ID, which is what lets the platform reject replayed requests, and it lets the platform discard a bad request before it touches the decryption key. See the PHP manual for the full openssl_encrypt() signature.
Send it with retries and tell someone when it fails
Each attempt builds a new request, with a new IV, timestamp and request ID, so a retry is never mistaken for a replay:
function rw_platform_create_account( int $user_id, string $password ): bool {
$user = get_userdata( $user_id );
$payload = array(
'wpUserId' => $user_id,
'email' => $user->user_email,
'firstName' => $user->first_name,
'lastName' => $user->last_name,
'password' => $password,
);
$error = '';
for ( $attempt = 1; $attempt <= 3; $attempt++ ) {
try {
$args = rw_platform_build_request( $payload );
} catch ( RuntimeException $e ) {
$error = $e->getMessage();
break;
}
$response = wp_remote_post( RW_PLATFORM_API_URL, $args );
$code = (int) wp_remote_retrieve_response_code( $response );
if ( ( $code >= 200 && $code < 300 ) || 409 === $code ) {
return true; // 409: an earlier attempt already created this account.
}
$error = is_wp_error( $response ) ? $response->get_error_code() : 'HTTP ' . $code;
if ( $code >= 400 && $code < 500 && 429 !== $code ) {
break; // A client error won't fix itself on retry.
}
if ( $attempt < 3 ) {
usleep( 300000 * $attempt );
}
}
error_log( sprintf( 'Platform sync failed for user %d: %s', $user_id, $error ) );
wp_mail(
get_option( 'admin_email' ),
'Platform account sync failed',
sprintf( "User %d could not be created on the platform (%s).\nNo password was stored. Ask the user to reset their password once the API is back.", $user_id, $error )
);
return false;
}
The worst case is three five-second timeouts plus short pauses, so the visitor waits under 20 seconds before seeing the “setup pending” page. Resist the urge to queue the password and retry later from WP-Cron: that means storing a recoverable password. When the sync fails, flag the account and let the user set a new password once the platform is reachable. The log and the email carry the user ID and the error code, never the payload.
For 409 to mean “already exists”, the platform endpoint has to be idempotent on wpUserId. Otherwise a retry after a lost response could create a duplicate account.
Verify and decrypt in ASP.NET Core
On the platform side, check the timestamp window, the request ID and the signature before decrypting anything. This minimal API endpoint targets .NET 8 or later:
// Program.cs, after builder.Services.AddMemoryCache() and var app = builder.Build();
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
app.MapPost("/v1/accounts", async (HttpRequest request, IConfiguration config,
IMemoryCache seenRequests, IAccountService accounts) =>
{
using var reader = new StreamReader(request.Body, Encoding.UTF8);
var body = await reader.ReadToEndAsync();
var timestamp = request.Headers["X-Timestamp"].ToString();
var requestId = request.Headers["X-Request-Id"].ToString();
var signature = request.Headers["X-Signature"].ToString();
if (!long.TryParse(timestamp, out var ts)
|| Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300
|| requestId.Length == 0 || seenRequests.TryGetValue(requestId, out _))
return Results.Unauthorized();
var hmacKey = Convert.FromBase64String(config["Signup:HmacKey"]!);
var expected = Convert.ToHexString(HMACSHA256.HashData(hmacKey,
Encoding.UTF8.GetBytes($"{timestamp}.{requestId}.{body}"))).ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(signature)))
return Results.Unauthorized();
seenRequests.Set(requestId, true, TimeSpan.FromMinutes(10));
var envelope = JsonSerializer.Deserialize<Envelope>(body, jsonOptions);
if (envelope is not { V: 1 }) return Results.BadRequest();
var cipher = Convert.FromBase64String(envelope.Data);
var plain = new byte[cipher.Length];
try
{
using var aes = new AesGcm(Convert.FromBase64String(config["Signup:EncKey"]!), 16);
aes.Decrypt(Convert.FromBase64String(envelope.Iv), cipher, Convert.FromBase64String(envelope.Tag), plain);
var signup = JsonSerializer.Deserialize<SignupRequest>(plain, jsonOptions)!;
// CreateAsync hashes the password with the platform's own hasher; false means the user exists.
return await accounts.CreateAsync(signup) ? Results.StatusCode(201) : Results.Conflict();
}
catch (CryptographicException)
{
return Results.BadRequest();
}
finally
{
CryptographicOperations.ZeroMemory(plain);
}
});
record Envelope(int V, string Iv, string Tag, string Data);
record SignupRequest(int WpUserId, string Email, string FirstName, string LastName, string Password);
The file needs using directives for System.Security.Cryptography, System.Text, System.Text.Json and Microsoft.Extensions.Caching.Memory, and app.Run() must come before the two record declarations. Keep both keys in configuration from a secret store, not in appsettings.json. IMemoryCache only protects a single server; with several instances, keep seen request IDs in a shared store such as Redis, with an atomic “set if not exists”.
Why syncing passwords is a trade-off
This bridge is sound, but it still copies a credential between two systems, and that has costs:
- Two places store a password hash, so there are two databases to protect and two hashing policies to keep current.
- The plaintext passes through WordPress, where any plugin hooked into the request could see it, including form plugins that save submissions.
- Every password change and reset has to reach both systems. If one sync fails, users can sign in on one side and not the other.
Two alternatives avoid those costs:
- Single sign-on with OpenID Connect. The platform, or a dedicated identity provider, owns the login. WordPress becomes a client that redirects to it and receives a signed ID token. Passwords live in one place, and multi-factor sign-in is configured once. On .NET, libraries such as OpenIddict or Duende IdentityServer can turn the platform into an OIDC provider.
- Token-based provisioning. WordPress calls the platform to create the account without a password, using the same signed request, and the platform emails the user a one-time link to set their password there. WordPress never handles the credential.
Syncing is still reasonable when the platform can’t act as an identity provider yet, users need to sign in directly on both systems, and the budget or timeline rules out SSO for now. In that case, treat the controls in this article as the minimum.
What to watch out for
- Exclude the sign-up page from page caching, or the nonce in the form will eventually expire for everyone.
- Turn off anything that logs request bodies: form plugins that store entries, HTTP debugging plugins and proxy logs on the platform side.
- Rotate keys by bumping
vin the envelope and accepting both versions for a short overlap. - Never reuse an IV with the same key. Random 12-byte IVs are safe at sign-up volumes.
- Keep server clocks in sync with NTP. The five-minute window depends on it.
- Rate-limit the sign-up form and add bot protection, because every sign-up triggers an API call to the platform.
This is the kind of work I take on for clients: Plugins and integrations