Callback Signature Verifier
Verifies callback signatures from SEP-12 anchor servers.
When anchors send webhook notifications about customer KYC status changes, they sign the request with their SIGNING_KEY to prove authenticity. This object provides utilities to verify these signatures and prevent spoofing.
Signature format (Signature or X-Stellar-Signature header):
t=<timestamp>, s=<base64_signature>Payload construction (signed data):
<timestamp>.<host>.<body>Where:
timestamp: Unix timestamp when signature was created
host: Expected callback host from the callback URL
body: Raw JSON request body
Security considerations:
Always verify callback signatures before processing
Check timestamp is within acceptable time window (default: 5 minutes)
Use anchor's SIGNING_KEY from their stellar.toml
Reject callbacks with expired timestamps
Verify host matches your registered callback URL
Example - Verify callback in webhook handler:
suspend fun handleKYCCallback(
signatureHeader: String,
requestBody: String,
callbackHost: String,
anchorSigningKey: String
) {
val isValid = CallbackSignatureVerifier.verify(
signatureHeader = signatureHeader,
requestBody = requestBody,
expectedHost = callbackHost,
anchorSigningKey = anchorSigningKey,
maxAgeSeconds = 300 // 5 minutes
)
if (!isValid) {
println("SECURITY WARNING: Invalid callback signature!")
throw SecurityException("Callback signature verification failed")
}
// Signature valid, process callback
val callback = Json.decodeFromString<CustomerCallback>(requestBody)
processCustomerUpdate(callback)
}Example - Ktor webhook endpoint:
routing {
post("/kyc-callback") {
// Get signature from header (try both standard and deprecated)
val signatureHeader = call.request.headers["Signature"]
?: call.request.headers["X-Stellar-Signature"]
?: run {
call.respond(HttpStatusCode.Unauthorized, "Missing signature header")
return@post
}
// Read request body
val requestBody = call.receiveText()
// Get anchor's signing key from stellar.toml
val stellarToml = StellarToml.fromDomain(anchorDomain)
val signingKey = stellarToml.generalInformation.signingKey
?: run {
call.respond(HttpStatusCode.InternalServerError, "Anchor signing key not found")
return@post
}
// Verify signature
val isValid = CallbackSignatureVerifier.verify(
signatureHeader = signatureHeader,
requestBody = requestBody,
expectedHost = "myapp.com",
anchorSigningKey = signingKey
)
if (!isValid) {
call.respond(HttpStatusCode.Unauthorized, "Invalid signature")
return@post
}
// Process callback
val callback = Json.decodeFromString<CustomerCallback>(requestBody)
handleCustomerStatusChange(callback)
call.respond(HttpStatusCode.OK)
}
}Example - Handle timestamp expiry:
suspend fun verifyCallbackWithLogging(
signatureHeader: String,
requestBody: String,
expectedHost: String,
anchorSigningKey: String
): Boolean {
try {
val isValid = CallbackSignatureVerifier.verify(
signatureHeader = signatureHeader,
requestBody = requestBody,
expectedHost = expectedHost,
anchorSigningKey = anchorSigningKey,
maxAgeSeconds = 300
)
if (!isValid) {
// Parse to get more details
val (timestamp, signature) = CallbackSignatureVerifier.parseSignatureHeader(signatureHeader)
val currentTime = Clock.System.now().epochSeconds
val age = currentTime - timestamp
if (age > 300) {
println("Callback signature expired (age: ${age}s)")
} else {
println("Callback signature invalid (not expired)")
}
}
return isValid
} catch (e: Exception) {
println("Signature verification error: ${e.message}")
return false
}
}Example - Custom time window:
// Allow older callbacks (e.g., 10 minutes) for testing
val isValid = CallbackSignatureVerifier.verify(
signatureHeader = signatureHeader,
requestBody = requestBody,
expectedHost = "localhost:8080",
anchorSigningKey = testAnchorKey,
maxAgeSeconds = 600 // 10 minutes for testing
)See also:
PutCustomerCallbackRequest for registering callback URLs
StellarToml for retrieving anchor signing keys
KeyPair for signature verification