Skip to content

Commit 25d73eb

Browse files
authored
Merge pull request #694 from tkhq/graham/webhooks-jwks-discovery
Document webhook JWKS key discovery endpoint and SDK verification helper
2 parents 5cd0e7e + ff2cdb7 commit 25d73eb

5 files changed

Lines changed: 216 additions & 24 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
title: "Get webhook JWKS"
3+
description: "Fetch Turnkey webhook signature verification keys."
4+
api: "GET https://api.turnkey.com/public/v1/discovery/webhooks/jwks"
5+
authMethod: "none"
6+
playground: "interactive"
7+
---
8+
9+
import { H3Bordered } from "/snippets/h3-bordered.mdx";
10+
import { NestedParam } from "/snippets/nested-param.mdx";
11+
12+
Fetch the public Ed25519 keys used to verify Turnkey webhook signatures. This endpoint requires no authentication.
13+
14+
For full verification guidance, including signed message construction and SDK helper usage, see [Verify webhook signatures](/features/webhooks/verify-signatures).
15+
16+
Cache the JWKS response according to the `Cache-Control` header. Match each JWK `kid` to the webhook delivery's `X-Turnkey-Signature-Key-Id` header, and refetch JWKS before rejecting a delivery with an unknown `kid`.
17+
18+
Current production `Cache-Control`:
19+
20+
```text
21+
public, max-age=86400, s-maxage=86400, stale-if-error=604800
22+
```
23+
24+
<H3Bordered text="Response" />
25+
A successful response returns the following fields:
26+
27+
<ResponseField name="keys" type="array" required={true}>
28+
Public webhook signature verification keys.
29+
<Expandable title="keys details">
30+
<NestedParam parentKey="keys" childKey="kid" type="string" required={true}>
31+
Key identifier. Match this value against the `X-Turnkey-Signature-Key-Id` delivery header.
32+
</NestedParam>
33+
<NestedParam parentKey="keys" childKey="kty" type="string" required={true}>
34+
Key type. The current value is `OKP` for Ed25519 public keys.
35+
</NestedParam>
36+
<NestedParam parentKey="keys" childKey="crv" type="string" required={true}>
37+
Key curve. The current value is `Ed25519`.
38+
</NestedParam>
39+
<NestedParam parentKey="keys" childKey="alg" type="string" required={true}>
40+
JWK algorithm. The current value is `EdDSA`.
41+
</NestedParam>
42+
<NestedParam parentKey="keys" childKey="use" type="string" required={true}>
43+
Key use. The current value is `sig` for signature verification.
44+
</NestedParam>
45+
<NestedParam parentKey="keys" childKey="x" type="string" required={true}>
46+
Base64url-encoded Ed25519 public key.
47+
</NestedParam>
48+
<NestedParam parentKey="keys" childKey="turnkey_signature_algorithm" type="string" required={true}>
49+
Turnkey signature algorithm metadata. The current value is `ed25519`.
50+
</NestedParam>
51+
<NestedParam parentKey="keys" childKey="turnkey_signature_version" type="string" required={true}>
52+
Turnkey signature contract version metadata. The current value is `v1`.
53+
</NestedParam>
54+
</Expandable>
55+
</ResponseField>
56+
57+
<ResponseExample>
58+
59+
```json 200
60+
{
61+
"keys": [
62+
{
63+
"kid": "<signing-key-id>",
64+
"kty": "OKP",
65+
"crv": "Ed25519",
66+
"alg": "EdDSA",
67+
"use": "sig",
68+
"x": "<base64url-encoded-public-key>",
69+
"turnkey_signature_algorithm": "ed25519",
70+
"turnkey_signature_version": "v1"
71+
}
72+
]
73+
}
74+
```
75+
76+
</ResponseExample>

docs.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,8 @@
452452
{
453453
"group": "Webhooks",
454454
"pages": [
455-
"features/webhooks/overview"
455+
"features/webhooks/overview",
456+
"features/webhooks/verify-signatures"
456457
]
457458
}
458459
]
@@ -619,6 +620,7 @@
619620
"api-reference/queries/list-users",
620621
"api-reference/queries/list-wallets",
621622
"api-reference/queries/list-wallets-accounts",
623+
"api-reference/queries/get-webhook-jwks",
622624
"api-reference/queries/list-webhook-endpoints",
623625
"api-reference/queries/validate-container-image-for-tvc",
624626
"api-reference/queries/who-am-i"

features/webhooks/overview.mdx

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ sidebarTitle: "Overview"
66

77
Webhooks deliver real-time notifications as signed HTTPS POST requests. Register an endpoint and subscribe to event types, and Turnkey will automatically deliver updates as they occur.
88

9-
- Signed deliveries with Ed25519 signatures
9+
- Signed deliveries with Ed25519 signatures -- see [Verify signatures](/features/webhooks/verify-signatures)
1010
- Organization-aware headers including org ID, event type, and timestamps on every delivery
1111
- Automatic retries for failed deliveries
1212
- Dashboard and API management for creating and configuring endpoints
@@ -123,26 +123,6 @@ Turnkey treats `2xx` responses as successful. Turnkey automatically retries retr
123123

124124
Signed retries receive a fresh timestamp and signature. `X-Turnkey-Event-Id` is signed delivery metadata and is stable across retry attempts for the same webhook event. Payload fields such as `msg.idempotencyKey` are event-specific business identifiers. Either may be useful for deduplication depending on the use case, but they are not the same field.
125125

126-
## Verify signatures
127-
128-
Verify the signature before parsing or trusting the webhook body. Signature verification requires the exact raw request body bytes that Turnkey sent. Re-serializing parsed JSON, changing whitespace, or changing key order will cause verification to fail.
129-
130-
The signed message follows this format:
131-
132-
```text
133-
v1.ed25519.<signing_key_id>.<timestamp_ms>.<event_id>.<raw_body>
134-
```
135-
136-
The `event_id` segment in the signed message is the value of the `X-Turnkey-Event-Id` header.
137-
138-
Signature verification signs only the signature contract fields shown above plus the raw body. Other delivery headers are useful for routing and observability but are not part of the signed message.
139-
140-
To verify a delivery, reconstruct the signed message from the headers and raw body, then verify the Ed25519 signature against the signing key identified by `X-Turnkey-Signature-Key-Id`. Ensure the timestamp is within an acceptable freshness window (e.g. 5 minutes) to guard against replay attacks.
141-
142-
<Note>
143-
An SDK verification helper and a key discovery endpoint are releasing soon to simplify this process.
144-
</Note>
145-
146126
## Payloads
147127

148128
### Activity updates
@@ -261,4 +241,4 @@ Read operations, such as listing webhook endpoints, use standard authenticated q
261241
| Empty or unclear endpoint names | Set `parameters.name` to a non-empty, human-readable name. |
262242
| Subscription shape errors | Pass event types inside `parameters.subscriptions[]`, not as top-level `eventTypes`. |
263243
| Invalid webhook URL errors | Use an HTTPS URL that resolves to a public destination. Localhost, private IPs, link-local addresses, metadata endpoints, and URLs with user info are rejected. |
264-
| Signature verification fails | Verify against the exact raw request body bytes, use the millisecond timestamp and event id from the headers, check clock skew, and select the public key matching the signature key id. |
244+
| Signature verification fails | See [Verify webhook signatures](/features/webhooks/verify-signatures). Common causes: re-serialized JSON body, clock skew, or missing signing key. |
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
---
2+
title: "Verify webhook signatures"
3+
description: "Verify Turnkey webhook signatures using the JWKS key discovery endpoint and the @turnkey/crypto SDK helper."
4+
sidebarTitle: "Verify signatures"
5+
---
6+
7+
Turnkey signs webhook deliveries with Ed25519. Verify the signature before parsing or trusting the JSON payload.
8+
9+
<Warning>
10+
Verification must use the exact raw request body bytes that Turnkey sent. Re-serializing parsed JSON, changing whitespace, or changing key order will cause verification to fail.
11+
</Warning>
12+
13+
## Signed message format
14+
15+
The signature covers the signature contract fields and the raw body:
16+
17+
```text
18+
v1.ed25519.<signing_key_id>.<timestamp_ms>.<event_id>.<raw_body>
19+
```
20+
21+
The `event_id` segment is the value of the `X-Turnkey-Event-Id` header. Other delivery headers such as organization ID and event type are not part of the signed message.
22+
23+
## Key discovery
24+
25+
Turnkey publishes webhook signing keys at a public JWKS endpoint. Use this endpoint to fetch the Ed25519 public key needed to verify webhook signatures. Match the `kid` field in the response to the `X-Turnkey-Signature-Key-Id` header on each delivery:
26+
27+
```text
28+
GET https://api.turnkey.com/public/v1/discovery/webhooks/jwks
29+
```
30+
31+
This endpoint requires no authentication. The response is a standard [JSON Web Key Set (RFC 7517)](https://datatracker.ietf.org/doc/html/rfc7517) containing Ed25519 public keys:
32+
33+
```json
34+
{
35+
"keys": [
36+
{
37+
"kid": "<signing-key-id>",
38+
"kty": "OKP",
39+
"crv": "Ed25519",
40+
"alg": "EdDSA",
41+
"use": "sig",
42+
"x": "<base64url-encoded-public-key>",
43+
"turnkey_signature_algorithm": "ed25519",
44+
"turnkey_signature_version": "v1"
45+
}
46+
]
47+
}
48+
```
49+
50+
| Field | Description |
51+
|---|---|
52+
| `kid` | Key identifier. Match this against the `X-Turnkey-Signature-Key-Id` delivery header. |
53+
| `kty` | Key type. `OKP` (Octet Key Pair) for Ed25519 keys. |
54+
| `crv` | Curve. `Ed25519`. |
55+
| `alg` | Algorithm. `EdDSA`. |
56+
| `use` | Key usage. `sig` (signature). |
57+
| `x` | Base64url-encoded 32-byte Ed25519 public key. |
58+
| `turnkey_signature_algorithm` | Turnkey-specific. Matches the `X-Turnkey-Signature-Algorithm` header value. |
59+
| `turnkey_signature_version` | Turnkey-specific. Matches the `X-Turnkey-Signature-Version` header value. |
60+
61+
The JWKS endpoint returns standard `Cache-Control` headers. Cache the response according to those headers and refresh on expiry. If a delivery arrives with a `kid` that does not match any cached key, refetch the JWKS before rejecting the delivery. This avoids stale-cache failures during key rotation while still allowing efficient caching.
62+
63+
## SDK verification helper
64+
65+
The `@turnkey/crypto` package (v2.10.0+) exports `verifyTurnkeyWebhookSignature`, which handles signed-input reconstruction, timestamp freshness, and Ed25519 verification. The helper accepts caller-provided verification keys and returns a typed result instead of throwing.
66+
67+
Fetch the JWKS endpoint, convert each key's base64url-encoded `x` field to hex, and pass the result as `verificationKeys`:
68+
69+
```ts
70+
import { verifyTurnkeyWebhookSignature } from "@turnkey/crypto";
71+
72+
const JWKS_URL =
73+
"https://api.turnkey.com/public/v1/discovery/webhooks/jwks";
74+
75+
// Fetch and cache the JWKS. Convert base64url public keys to hex.
76+
async function fetchVerificationKeys() {
77+
const res = await fetch(JWKS_URL);
78+
const jwks = await res.json();
79+
return jwks.keys.map((key: any) => ({
80+
keyId: key.kid,
81+
publicKey: Buffer.from(key.x, "base64url").toString("hex"),
82+
algorithm: "ed25519" as const,
83+
}));
84+
}
85+
86+
const verificationKeys = await fetchVerificationKeys();
87+
88+
// In your webhook handler:
89+
const rawBody = await request.text();
90+
91+
const result = verifyTurnkeyWebhookSignature({
92+
headers: request.headers,
93+
body: rawBody,
94+
verificationKeys,
95+
maxTimestampAgeMs: 5 * 60 * 1000, // 5-minute replay window
96+
});
97+
98+
if (!result.ok) {
99+
// result.reason describes the failure (e.g. "stale_timestamp", "missing_key")
100+
return new Response("Invalid signature", { status: 401 });
101+
}
102+
103+
// Signature verified. Safe to parse.
104+
const payload = JSON.parse(rawBody);
105+
```
106+
107+
<Warning>
108+
Always read the raw request body before any middleware parses it. Frameworks like Express require `express.raw({ type: "application/json" })` to preserve the original bytes. If your framework has already parsed the body, the re-serialized output may differ from what Turnkey signed.
109+
</Warning>
110+
111+
## Manual verification
112+
113+
If you are not using the TypeScript SDK, verify signatures manually:
114+
115+
1. Fetch the JWKS from `https://api.turnkey.com/public/v1/discovery/webhooks/jwks`.
116+
2. Find the key whose `kid` matches the `X-Turnkey-Signature-Key-Id` header. Base64url-decode the `x` field to obtain the 32-byte Ed25519 public key.
117+
3. Reconstruct the signed input by concatenating the header values and raw body in the format: `v1.ed25519.<key_id>.<timestamp_ms>.<event_id>.<raw_body>`.
118+
4. Hex-decode the `X-Turnkey-Signature` header to obtain the 64-byte Ed25519 signature.
119+
5. Verify the Ed25519 signature over the signed input bytes using the public key from step 2.
120+
6. Check that `X-Turnkey-Timestamp` is within an acceptable freshness window (e.g. 5 minutes) to guard against replay attacks.

scripts/openapi-gen/openapi-gen.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ export const tags = ${tagsStr};`;
193193
const uniqueActivityPaths = [...new Set(activityPaths)];
194194
const uniqueQueryPaths = [...new Set(queryPaths)];
195195
const uniqueAuthProxyPaths = [...new Set(authProxyPaths)];
196+
const manualQueryPaths = ["api-reference/queries/get-webhook-jwks"];
196197

197198
// Sort generated paths alphabetically
198199
uniqueActivityPaths.sort();
@@ -253,10 +254,23 @@ export const tags = ${tagsStr};`;
253254
(item: any) => typeof item === "object" && item.group === "Queries"
254255
);
255256
if (queriesGroup) {
256-
queriesGroup.pages = [
257+
const queryPages = [
257258
"api-reference/queries/overview",
258259
...uniqueQueryPaths,
259260
];
261+
for (const manualPath of manualQueryPaths) {
262+
if (queryPages.includes(manualPath)) continue;
263+
264+
const webhookEndpointsIndex = queryPages.indexOf(
265+
"api-reference/queries/list-webhook-endpoints"
266+
);
267+
if (webhookEndpointsIndex === -1) {
268+
queryPages.push(manualPath);
269+
} else {
270+
queryPages.splice(webhookEndpointsIndex, 0, manualPath);
271+
}
272+
}
273+
queriesGroup.pages = queryPages;
260274
console.log(`Updated Queries paths in docs.json`);
261275
} else {
262276
console.warn(

0 commit comments

Comments
 (0)