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