|
| 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