Skip to content

Commit 55df9a2

Browse files
authored
Merge pull request #776 from craftcms/timkelty/add-signed-request-docs
Document Cloud request signing
2 parents 575f86a + 02fc125 commit 55df9a2

2 files changed

Lines changed: 135 additions & 0 deletions

File tree

docs/.vuepress/sets/craft-cloud.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ module.exports = {
5353
"compatibility",
5454
"static-caching",
5555
"esi",
56+
"request-signing",
5657
"quotas",
5758
"licensing",
5859
"backups",

docs/cloud/request-signing.md

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
---
2+
description: Sign trusted programmatic requests to avoid bot rate limiting.
3+
---
4+
5+
# Request Signing
6+
7+
Request signing allows trusted systems to make programmatic requests to Craft Cloud without being treated like unsanctioned bot traffic.
8+
9+
This is useful for automated systems like static site builds or CI/CD pipelines, which will often be identified (correctly!) as “bots” and be rate-limited more aggressively than browsers.
10+
11+
Each environment’s `$CRAFT_CLOUD_SIGNING_KEY` [system variable](environments.md#variables) is used as a shared secret when generating and validating signed requests.
12+
13+
::: tip
14+
For more details on RFC 9421 HTTP Message Signatures, see [httpsig.org](https://httpsig.org/).
15+
:::
16+
17+
## Creating a Signed Request
18+
19+
External systems can generate valid signatures for a Craft Cloud environment, provided the corresponding `$CRAFT_CLOUD_SIGNING_KEY`.
20+
21+
Signatures are valid at the Craft Cloud gateway for a maximum of **five minutes**.
22+
A signed request is not consumed (like a token URL is, in Craft), and they are not idempotent.
23+
24+
### From Node.js
25+
26+
This example uses [`http-message-sig`](https://www.npmjs.com/package/http-message-sig) to generate an RFC 9421-compliant signature:
27+
28+
```bash
29+
npm install http-message-sig
30+
```
31+
32+
Build and send a signed request like this:
33+
34+
```js
35+
import crypto from 'node:crypto';
36+
import { signatureHeadersSync } from 'http-message-sig';
37+
38+
const method = 'POST';
39+
40+
// These variables/secrets can be store in (and fetched from) the environment, instead:
41+
const url = 'https://my-env.some-domain.com/api';
42+
const schemaToken = 'WcVqivS64CCRQN9ohVcKk5FB6RIFTApd';
43+
44+
const body = JSON.stringify({
45+
query: `
46+
{
47+
entries(section: "blog") {
48+
title
49+
url
50+
}
51+
}
52+
`,
53+
});
54+
55+
const headers = {
56+
'Content-Type': 'application/json',
57+
'Authorization': `Bearer ${schemaToken}`,
58+
};
59+
60+
const created = new Date();
61+
62+
const signer = {
63+
keyid: 'hmac',
64+
alg: 'hmac-sha256',
65+
signSync(data) {
66+
return crypto
67+
.createHmac('sha256', process.env.CRAFT_CLOUD_SIGNING_KEY)
68+
.update(data)
69+
.digest();
70+
},
71+
};
72+
73+
const signatureHeaders = signatureHeadersSync(
74+
{ method, url, headers, body },
75+
{
76+
key: 'sig',
77+
signer,
78+
components: ['@method', '@target-uri'],
79+
created,
80+
// This is optional (and cannot exceed five minutes, to validate at the edge):
81+
expires: new Date(created.getTime() + 60_000),
82+
},
83+
);
84+
85+
const response = await fetch(url, {
86+
method,
87+
headers: {
88+
...headers,
89+
...signatureHeaders,
90+
},
91+
body,
92+
});
93+
94+
const result = await response.json();
95+
```
96+
97+
::: tip
98+
Requests signed using the `@target-uri` [component](https://www.rfc-editor.org/rfc/rfc9421.html#name-derived-components) are only valid when sent to a URL that matches _exactly_, including the scheme, hostname, path, and query string.
99+
The example above satisfies this by using the same `url` variable for the signed request and the `fetch()` call.
100+
:::
101+
102+
### From Craft
103+
104+
Any Craft project running on Cloud can sign requests.
105+
This can be useful when making HTTP requests from a console command, queue job, or for communication between environments or projects.
106+
107+
```php
108+
use Craft;
109+
110+
use craft\cloud\Module;
111+
use GuzzleHttp\Psr7\Request;
112+
113+
$signer = Module::getInstance()->getRequestSigner();
114+
115+
$request = new Request(
116+
'POST',
117+
'https://api.example.test/webhook',
118+
['Content-Type' => 'application/json'],
119+
json_encode([
120+
'event' => 'order.paid',
121+
], JSON_THROW_ON_ERROR),
122+
);
123+
124+
$signedRequest = $signer->sign($request);
125+
126+
$response = Craft::createGuzzleClient()->send($signedRequest);
127+
```
128+
129+
## Signature Verification
130+
131+
Craft Cloud automatically tries to validate signed requests, at the gateway.
132+
If validation _fails_, normal bot- and rate-limiting rules are applied; if no policies are triggered, the request is forwarded to Craft like any other.
133+
134+
Once the request reaches your application, you are free to perform additional verification (like checking a separate shared secret).

0 commit comments

Comments
 (0)