Skip to main content

Webhook Signatures

OneTap Login PRO signs all webhook payloads using HMAC-SHA256, allowing you to verify that webhook requests genuinely originated from your WordPress site.

PRO Feature

Webhook signatures are part of the PRO webhooks feature. Upgrade to PRO to use webhooks.

Overview​

What Are Webhook Signatures?​

Webhook signatures are cryptographic hashes that prove:

  • The payload came from OneTap Login
  • The payload wasn't modified in transit
  • The request is authentic, not forged

How Signatures Work​

Webhook event occurs
↓
OneTap serializes payload to JSON
↓
Creates HMAC-SHA256 hash:
hash = HMAC(payload, secret_key)
↓
Adds signature to header:
X-OneTap-Signature: sha256=abc123...
↓
Sends webhook request
↓
Your server receives request
↓
You verify signature
↓
Valid → Process payload
↓
Invalid → Reject request

Signature Header​

Header Format​

Every webhook request includes:

POST /your-webhook-endpoint HTTP/1.1
Host: your-server.com
Content-Type: application/json
X-OneTap-Signature: sha256=a1b2c3d4e5f6...
X-OneTap-Event: user_registered
X-OneTap-Timestamp: 1705312800

{"user_id": 123, "email": "user@example.com", ...}

Header Details​

HeaderDescription
X-OneTap-SignatureHMAC-SHA256 signature with sha256= prefix
X-OneTap-EventEvent type (user_registered or user_logged_in)
X-OneTap-TimestampUnix timestamp when sent

Creating the Signature​

Algorithm​

OneTap uses HMAC-SHA256:

// OneTap creates signature like this:
$payload_json = json_encode($payload);
$signature = hash_hmac('sha256', $payload_json, $webhook_secret);
$header_value = 'sha256=' . $signature;

What Gets Signed​

The signature covers:

  • Entire JSON payload
  • Exactly as sent (byte-for-byte)

Does NOT include:

  • HTTP headers
  • URL
  • Timestamp (verify separately)

Verifying Signatures​

PHP Verification​

<?php
// Your webhook secret (from OneTap settings)
$webhook_secret = 'your_webhook_secret_here';

// Get the signature header
$signature_header = $_SERVER['HTTP_X_ONETAP_SIGNATURE'] ?? '';

// Get the raw payload
$payload = file_get_contents('php://input');

// Calculate expected signature
$expected_signature = 'sha256=' . hash_hmac('sha256', $payload, $webhook_secret);

// Compare signatures (timing-safe)
if (!hash_equals($expected_signature, $signature_header)) {
http_response_code(401);
die('Invalid signature');
}

// Signature valid - process payload
$data = json_decode($payload, true);
// Process $data...

Node.js Verification​

const crypto = require('crypto');

const webhookSecret = 'your_webhook_secret_here';

function verifySignature(payload, signatureHeader) {
const expectedSignature = 'sha256=' +
crypto.createHmac('sha256', webhookSecret)
.update(payload)
.digest('hex');

// Timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(signatureHeader)
);
}

// Express.js example
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
const signatureHeader = req.headers['x-onetap-signature'];
const payload = req.body.toString();

if (!verifySignature(payload, signatureHeader)) {
return res.status(401).send('Invalid signature');
}

const data = JSON.parse(payload);
// Process data...
res.status(200).send('OK');
});

Python Verification​

import hmac
import hashlib

webhook_secret = 'your_webhook_secret_here'

def verify_signature(payload: bytes, signature_header: str) -> bool:
expected = 'sha256=' + hmac.new(
webhook_secret.encode(),
payload,
hashlib.sha256
).hexdigest()

return hmac.compare_digest(expected, signature_header)

# Flask example
from flask import Flask, request

@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-OneTap-Signature', '')
payload = request.get_data()

if not verify_signature(payload, signature):
return 'Invalid signature', 401

data = request.get_json()
# Process data...
return 'OK', 200

Ruby Verification​

require 'openssl'

WEBHOOK_SECRET = 'your_webhook_secret_here'

def verify_signature(payload, signature_header)
expected = 'sha256=' + OpenSSL::HMAC.hexdigest(
'sha256',
WEBHOOK_SECRET,
payload
)

Rack::Utils.secure_compare(expected, signature_header)
end

# Sinatra/Rails example
post '/webhook' do
payload = request.body.read
signature = request.env['HTTP_X_ONETAP_SIGNATURE']

unless verify_signature(payload, signature)
halt 401, 'Invalid signature'
end

data = JSON.parse(payload)
# Process data...
status 200
end

Timestamp Validation​

Preventing Replay Attacks​

Also validate the timestamp:

<?php
$timestamp = $_SERVER['HTTP_X_ONETAP_TIMESTAMP'] ?? 0;
$current_time = time();
$tolerance = 300; // 5 minutes

// Reject if too old
if (abs($current_time - $timestamp) > $tolerance) {
http_response_code(401);
die('Timestamp too old');
}

Complete Verification​

<?php
function verify_webhook($secret) {
// 1. Check timestamp
$timestamp = $_SERVER['HTTP_X_ONETAP_TIMESTAMP'] ?? 0;
if (abs(time() - $timestamp) > 300) {
return false;
}

// 2. Get signature
$signature = $_SERVER['HTTP_X_ONETAP_SIGNATURE'] ?? '';
if (empty($signature)) {
return false;
}

// 3. Get payload
$payload = file_get_contents('php://input');
if (empty($payload)) {
return false;
}

// 4. Verify signature
$expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);
return hash_equals($expected, $signature);
}

// Usage
if (!verify_webhook('your_secret')) {
http_response_code(401);
die('Unauthorized');
}

// Process webhook...

Secret Key Management​

Generating a Secret​

OneTap generates a secure random secret:

// How OneTap generates secrets
$secret = bin2hex(random_bytes(32)); // 64 character hex string

Where to Find Your Secret​

  1. Go to Settings > OneTap Login > Integrations
  2. Look for Webhook Secret field
  3. Copy the secret

Webhook Secret Field

Regenerating the Secret​

To get a new secret:

  1. Click "Regenerate Secret" button
  2. Copy new secret
  3. Update all webhook receivers
  4. Save settings

Warning: Regenerating invalidates the old secret immediately.

Secret Security​

DoDon't
Store in environment variablesHardcode in source code
Use secrets managementCommit to version control
Rotate periodicallyShare across environments
Limit accessLog the secret

Error Handling​

Common Verification Errors​

ErrorCauseSolution
Empty signatureHeader missingCheck header name case
Wrong signatureSecret mismatchVerify secret is correct
Payload mismatchBody modifiedUse raw body, not parsed
Encoding issueCharacter encodingEnsure UTF-8

Logging Failures​

<?php
function verify_and_log($secret) {
$signature = $_SERVER['HTTP_X_ONETAP_SIGNATURE'] ?? '';
$payload = file_get_contents('php://input');

$expected = 'sha256=' . hash_hmac('sha256', $payload, $secret);

if (!hash_equals($expected, $signature)) {
error_log('Webhook signature mismatch:');
error_log('Received: ' . $signature);
error_log('Expected: ' . $expected);
error_log('Payload length: ' . strlen($payload));
return false;
}

return true;
}

Testing Signatures​

Verify Manually​

<?php
// Test script
$secret = 'your_test_secret';
$payload = '{"user_id":123,"email":"test@example.com"}';

$signature = 'sha256=' . hash_hmac('sha256', $payload, $secret);
echo "Signature: " . $signature;

// Use this to verify your implementation

Testing with curl​

# Generate signature
SECRET="your_secret"
PAYLOAD='{"user_id":123,"email":"test@example.com"}'
SIGNATURE="sha256=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)"

# Send test request
curl -X POST https://your-server.com/webhook \
-H "Content-Type: application/json" \
-H "X-OneTap-Signature: $SIGNATURE" \
-H "X-OneTap-Event: user_registered" \
-H "X-OneTap-Timestamp: $(date +%s)" \
-d "$PAYLOAD"

Zapier & Make Integration​

Zapier​

Zapier doesn't support custom signature verification natively. Options:

  1. Use webhook URL as shared secret (less secure)
  2. Add signature check in a Code step
  3. Use Zapier's built-in webhook authentication

Make (Integromat)​

Make supports custom header validation:

  1. Use "Webhook" module
  2. Add "JSON" parser
  3. Use filter module to verify signature
// Make.com Code module
const crypto = require('crypto');

const secret = 'your_secret';
const payload = JSON.stringify(body);
const receivedSig = headers['x-onetap-signature'];

const expectedSig = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');

if (receivedSig !== expectedSig) {
throw new Error('Invalid signature');
}

return body;

Security Best Practices​

Do's​

  • Always verify signatures in production
  • Use timing-safe comparison functions
  • Validate timestamps
  • Store secrets securely
  • Rotate secrets periodically
  • Log verification failures

Don'ts​

  • Skip verification for "testing"
  • Use simple string comparison
  • Accept old timestamps
  • Log the actual secret
  • Use the same secret across environments

Debugging​

Signature Mismatch Checklist​

  1. Correct secret?

    • Copy directly from OneTap settings
    • No extra whitespace
  2. Raw payload?

    • Use file_get_contents('php://input')
    • Don't use $_POST (already parsed)
  3. Same encoding?

    • UTF-8 throughout
    • No BOM characters
  4. Header name correct?

    • X-OneTap-Signature
    • In PHP: HTTP_X_ONETAP_SIGNATURE
  5. Full signature value?

    • Includes sha256= prefix
    • 64+ characters total

Next Steps​