CallbackSignatureVerifier

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:

Functions

Link copied to clipboard

Parses the signature header into timestamp and signature components.

Link copied to clipboard
suspend fun verify(signatureHeader: String, requestBody: String, expectedHost: String, anchorSigningKey: String, maxAgeSeconds: Long = 300): Boolean

Verifies a callback signature from a SEP-12 anchor.