Manual Webhook Verification¶
Low-level API for manual RSA-SHA256 signature verification.
See also: Webhooks Guide | Framework Integration
AuthVitalWebhooks Class¶
For more control over webhook handling, use the AuthVitalWebhooks class directly.
import { AuthVitalWebhooks } from '@authvital/sdk/webhooks';
const webhooks = new AuthVitalWebhooks({
authVitalHost: process.env.AV_HOST!,
maxTimestampAge: 300, // Optional: 5 min replay protection
keysCacheTtl: 3600000, // Optional: 1 hour JWKS cache
});
Verifying a Webhook Signature¶
import express from 'express';
import { AuthVitalWebhooks, WebhookVerificationError } from '@authvital/sdk/webhooks';
const app = express();
const webhooks = new AuthVitalWebhooks({
authVitalHost: process.env.AV_HOST!,
});
app.post(
'/webhooks/authvital',
express.raw({ type: 'application/json' }),
async (req, res) => {
try {
// Extract headers
const signature = req.headers['x-authvital-signature'] as string;
const keyId = req.headers['x-authvital-key-id'] as string;
const timestamp = req.headers['x-authvital-timestamp'] as string;
const eventId = req.headers['x-authvital-event-id'] as string;
const eventType = req.headers['x-authvital-event-type'] as string;
// Get raw body as string
const body = req.body.toString('utf-8');
// Verify signature (throws on failure)
const event = await webhooks.verifyAndParse({
body,
signature,
keyId,
timestamp,
});
console.log('Verified event:', event.type, event.id);
// Handle the event based on type
switch (event.type) {
case 'subject.created':
console.log('New user:', event.data.email);
break;
case 'member.joined':
console.log('Member joined:', event.data.membership_id);
break;
// ... handle other events
}
res.status(200).json({ received: true });
} catch (error) {
if (error instanceof WebhookVerificationError) {
console.error('Webhook verification failed:', error.message);
return res.status(401).json({ error: error.message });
}
console.error('Webhook processing error:', error);
return res.status(500).json({ error: 'Internal error' });
}
}
);
Manual RSA-SHA256 Verification (Without SDK)¶
If you can't use the SDK, here's how to verify webhooks manually:
import crypto from 'crypto';
// 1. Fetch JWKS from AuthVital
interface JWK {
kty: string;
kid: string;
n: string; // RSA modulus (base64url)
e: string; // RSA exponent (base64url)
}
interface JWKS {
keys: JWK[];
}
async function fetchJWKS(authVitalHost: string): Promise<JWKS> {
const response = await fetch(`${authVitalHost}/.well-known/jwks.json`);
if (!response.ok) {
throw new Error(`Failed to fetch JWKS: ${response.status}`);
}
return response.json();
}
// 2. Convert JWK to PEM format
function jwkToPem(jwk: JWK): string {
// Create RSA public key from JWK
const key = crypto.createPublicKey({
key: {
kty: 'RSA',
n: jwk.n,
e: jwk.e,
},
format: 'jwk',
});
return key.export({ type: 'spki', format: 'pem' }) as string;
}
// 3. Verify the webhook signature
interface VerifyWebhookParams {
body: string; // Raw request body
signature: string; // X-AuthVital-Signature header (base64)
keyId: string; // X-AuthVital-Key-Id header
timestamp: string; // X-AuthVital-Timestamp header
authVitalHost: string;
maxTimestampAge?: number; // Seconds (default: 300)
}
async function verifyWebhook(params: VerifyWebhookParams): Promise<boolean> {
const {
body,
signature,
keyId,
timestamp,
authVitalHost,
maxTimestampAge = 300,
} = params;
// Check timestamp for replay protection
const timestampNum = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestampNum) > maxTimestampAge) {
throw new Error('Webhook timestamp too old or in the future');
}
// Fetch JWKS and find the key
const jwks = await fetchJWKS(authVitalHost);
const jwk = jwks.keys.find((k) => k.kid === keyId);
if (!jwk) {
throw new Error(`Key not found in JWKS: ${keyId}`);
}
// Convert to PEM
const publicKeyPem = jwkToPem(jwk);
// Create signature payload: "{timestamp}.{body}"
const signaturePayload = `${timestamp}.${body}`;
// Verify RSA-SHA256 signature
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(signaturePayload);
verifier.end();
const signatureBuffer = Buffer.from(signature, 'base64');
const isValid = verifier.verify(publicKeyPem, signatureBuffer);
return isValid;
}
Usage Example (Manual Verification)¶
import express from 'express';
const app = express();
app.post(
'/webhooks/authvital',
express.raw({ type: 'application/json' }),
async (req, res) => {
try {
const isValid = await verifyWebhook({
body: req.body.toString('utf-8'),
signature: req.headers['x-authvital-signature'] as string,
keyId: req.headers['x-authvital-key-id'] as string,
timestamp: req.headers['x-authvital-timestamp'] as string,
authVitalHost: process.env.AV_HOST!,
});
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body.toString('utf-8'));
console.log('Verified event:', event.type);
// Handle event based on type
switch (event.type) {
case 'subject.created':
await handleSubjectCreated(event);
break;
case 'member.joined':
await handleMemberJoined(event);
break;
// ... other events
}
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook verification failed:', error);
res.status(401).json({ error: 'Verification failed' });
}
}
);
JWKS Caching¶
In production, cache the JWKS to avoid fetching on every request:
class JWKSCache {
private keys: Map<string, string> = new Map(); // kid -> PEM
private lastFetch: number = 0;
private ttl: number;
private authVitalHost: string;
constructor(authVitalHost: string, ttlMs: number = 3600000) {
this.authVitalHost = authVitalHost;
this.ttl = ttlMs;
}
async getPublicKey(keyId: string): Promise<string> {
// Refresh cache if expired
if (Date.now() - this.lastFetch > this.ttl) {
await this.refresh();
}
const pem = this.keys.get(keyId);
if (!pem) {
// Key not found, try refreshing
await this.refresh();
const refreshedPem = this.keys.get(keyId);
if (!refreshedPem) {
throw new Error(`Key not found: ${keyId}`);
}
return refreshedPem;
}
return pem;
}
private async refresh(): Promise<void> {
const jwks = await fetchJWKS(this.authVitalHost);
this.keys.clear();
for (const jwk of jwks.keys) {
this.keys.set(jwk.kid, jwkToPem(jwk));
}
this.lastFetch = Date.now();
}
}
// Usage
const jwksCache = new JWKSCache(process.env.AV_HOST!, 3600000);
async function verifyWithCache(params: Omit<VerifyWebhookParams, 'authVitalHost'>) {
const { body, signature, keyId, timestamp, maxTimestampAge = 300 } = params;
// Check timestamp
const timestampNum = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestampNum) > maxTimestampAge) {
throw new Error('Webhook timestamp too old');
}
// Get public key from cache
const publicKeyPem = await jwksCache.getPublicKey(keyId);
// Verify signature
const signaturePayload = `${timestamp}.${body}`;
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(signaturePayload);
verifier.end();
return verifier.verify(publicKeyPem, Buffer.from(signature, 'base64'));
}
Python Example¶
import hashlib
import time
from typing import Any
import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
import jwt
import base64
class AuthVitalWebhookVerifier:
def __init__(self, authvital_host: str, max_timestamp_age: int = 300):
self.authvital_host = authvital_host
self.max_timestamp_age = max_timestamp_age
self._jwks_cache = None
self._jwks_cache_time = 0
self._cache_ttl = 3600 # 1 hour
def _fetch_jwks(self) -> dict:
"""Fetch JWKS from AuthVital."""
now = time.time()
if self._jwks_cache and (now - self._jwks_cache_time) < self._cache_ttl:
return self._jwks_cache
url = f"{self.authvital_host}/.well-known/jwks.json"
response = requests.get(url)
response.raise_for_status()
self._jwks_cache = response.json()
self._jwks_cache_time = now
return self._jwks_cache
def _get_public_key(self, key_id: str):
"""Get public key from JWKS by key ID."""
jwks = self._fetch_jwks()
for key in jwks.get('keys', []):
if key.get('kid') == key_id:
return jwt.algorithms.RSAAlgorithm.from_jwk(key)
raise ValueError(f"Key not found: {key_id}")
def verify(self, body: str, signature: str, key_id: str, timestamp: str) -> bool:
"""Verify webhook signature."""
# Check timestamp for replay protection
ts = int(timestamp)
now = int(time.time())
if abs(now - ts) > self.max_timestamp_age:
raise ValueError("Timestamp too old or in the future")
# Get public key
public_key = self._get_public_key(key_id)
# Create signature payload
payload = f"{timestamp}.{body}".encode('utf-8')
# Decode signature
sig_bytes = base64.b64decode(signature)
# Verify RSA-SHA256 signature
try:
public_key.verify(
sig_bytes,
payload,
padding.PKCS1v15(),
hashes.SHA256()
)
return True
except Exception:
return False
def verify_and_parse(self, body: str, signature: str, key_id: str, timestamp: str) -> dict:
"""Verify signature and parse event."""
if not self.verify(body, signature, key_id, timestamp):
raise ValueError("Invalid signature")
import json
return json.loads(body)
# Flask example
from flask import Flask, request, jsonify
app = Flask(__name__)
verifier = AuthVitalWebhookVerifier(os.environ['AV_HOST'])
@app.route('/webhooks/authvital', methods=['POST'])
def handle_webhook():
try:
event = verifier.verify_and_parse(
body=request.get_data(as_text=True),
signature=request.headers.get('X-AuthVital-Signature'),
key_id=request.headers.get('X-AuthVital-Key-Id'),
timestamp=request.headers.get('X-AuthVital-Timestamp'),
)
print(f"Verified event: {event['type']}")
if event['type'] == 'subject.created':
handle_subject_created(event)
elif event['type'] == 'member.joined':
handle_member_joined(event)
# ... handle other events
return jsonify({'received': True})
except ValueError as e:
return jsonify({'error': str(e)}), 401
Related Documentation¶
- Webhooks Guide - Overview and quick start
- Event Types & Payloads - All event types
- Framework Integration - Express, Next.js, NestJS
- Best Practices - Error handling, testing