Skip to content

Request signing

GraphPilot signs every request it forwards to your origin with a per-service signing key and adds the signature to the request headers. Signing is always on. To verify those signatures you need the key, which the portal shows you in full once: when you create the service there, and again each time you rotate it. Verifying is up to your origin, and it is recommended.

A valid signature confirms that a request passed through GraphPilot rather than reaching your origin directly. That matters most when you rely on GraphPilot to enforce token or API-key validation: a signed request has been through those checks, an unsigned or invalid one has not. Signing does not replace TLS or your own user authentication.

A service is issued its signing key when it is created, and is signed with it from its very first request. The key is shown once: in the portal, in the dialog that follows creating the service; from the CLI, in the output of gpilot service create. Copy it then and deploy it to your origin, and your origin can verify from the first request it receives.

The key is a secret. Treat it like a password: store it in a secret manager, inject it into your origin through your platform’s secret store, and never commit it to source control. Anyone holding it can forge valid signatures.

The full key appears in one dialog, Copy your signing key after creating the service or Copy your new signing key after a rotation, in a read-only field with a copy button. Escape and a click outside it do nothing, and it has no close cross: the only way out is I have copied the key, so the key cannot be lost to a stray click. After that, the service page only ever shows the mask.

GraphPilot adds two headers to each forwarded request:

GP-Timestamp: 1718700000
GP-Signature: v1=Base64(HMAC-SHA256(signing_key, canonical_string))
  • GP-Timestamp is the time the request was signed, as Unix seconds. It is part of the signed data and drives replay protection.
  • GP-Signature carries the signature, Base64-encoded, prefixed with a scheme version (v1=) so the scheme can evolve without breaking verifiers.

Header names are case-insensitive: gp-signature and GP-Signature are the same header.

The signature is an HMAC-SHA256 over a canonical string: a deterministic representation of the request that both sides build the same way. It is the following four fields, joined by a single newline (\n):

{HTTP_METHOD}
{PATH_AND_QUERY}
{TIMESTAMP}
{BODY_HASH}
FieldDescription
HTTP_METHODThe request method, uppercased (e.g. POST, or QUERY if your service forwards reads as QUERY).
PATH_AND_QUERYThe request target as sent to your origin: the path including the query string (e.g. /graphql?op=ping).
TIMESTAMPThe same value as the GP-Timestamp header.
BODY_HASHLowercase hex of SHA-256(raw_body_bytes). For a request with no body, this is the SHA-256 of the empty string.

Binding the method and path stops a captured signature from being replayed against a different endpoint.

The signature is then:

signature = Base64( HMAC-SHA256(signing_key, canonical_string) )

The HMAC key is the complete key string as UTF-8 bytes, gpsk_ prefix included. Do not strip the prefix, and do not base64-decode the rest, even though it looks like base64: the string you copied is the key exactly as it is.

The signature covers the four fields above: the method, the path with its query string, the timestamp and the SHA-256 of the body. It covers no request headers.

A valid signature proves that the request came through your GraphPilot service and that those four parts were not changed on the way. It says nothing about whether the other headers are true. In particular, it does not vouch for forwarding headers a client set itself, such as X-Forwarded-For, X-Forwarded-Host, X-Forwarded-Proto, Forwarded or X-Real-IP.

  • Do not trust forwarding headers because a request is signed. This is the “trust proxy” setting in many frameworks. By default GraphPilot removes the forwarding headers a client sent before the request reaches your origin (origin.forwarded_headers, default "strip"). With "pass", your origin receives them exactly as the client sent them, so a framework that trusts them takes the client’s word for its address, host and scheme.
  • For the client’s real address, read gp-client-ip. With origin.forward_client_ip on, the edge sets this header to the address it saw for the connection. A client cannot forge it: a value a client sends is removed, and the edge writes its own. See request headers your origin receives.

Reject any request that fails verification. The steps:

  1. Read GP-Timestamp and GP-Signature. If either is missing, reject the request.

  2. Check the timestamp against your replay window. If it is too old or too far in the future, reject the request.

  3. Rebuild the canonical string from the request, using the raw request body (the exact bytes received, before any JSON parsing or re-serialization).

  4. Compute Base64(HMAC-SHA256(signing_key, canonical_string)).

  5. Compare your value against the one in the header using a constant-time comparison, to avoid leaking information through timing. Accept only on a match.

import { createHmac, createHash, timingSafeEqual } from "node:crypto"
const TOLERANCE_SECONDS = 300
// `rawBody` must be the exact bytes received (Buffer or string),
// not a re-serialized object.
export function verifyGraphPilotSignature({ method, pathAndQuery, rawBody, headers, signingKey }) {
const timestamp = headers["gp-timestamp"]
const signatureHeader = headers["gp-signature"]
if (!timestamp || !signatureHeader) return false
// Reject stale or future timestamps.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false
const bodyHash = createHash("sha256").update(rawBody).digest("hex")
const canonical = [method.toUpperCase(), pathAndQuery, timestamp, bodyHash].join("\n")
const expected = createHmac("sha256", signingKey).update(canonical).digest("base64")
// Header looks like "v1=<base64>".
const provided = signatureHeader.replace(/^v1=/, "")
const a = Buffer.from(expected)
const b = Buffer.from(provided)
return a.length === b.length && timingSafeEqual(a, b)
}

Check your implementation against a known vector

Section titled “Check your implementation against a known vector”

Run your verifier against this vector before you trust it in production. GraphPilot’s own test suite pins the same one.

signing key
gpsk_test_key
request body (no trailing newline)
{"query":"{ me { id } }"}
canonical string (three newlines, no trailing newline)
POST
/graphql?op=ping
1718700000
64d4ae404c393f3916a308845f1df6e2ccd4243d889d3883f9b2dd7da9d31114
expected signature
708YYmz8ifG4D6z8Fl1XVh1oIDtn8NFRhNkTZPR+ZWM=

The last line of the canonical string is the SHA-256 of that body, so the vector checks your body hashing as well as the HMAC. The header your origin would receive for it is GP-Signature: v1=708YYmz8ifG4D6z8Fl1XVh1oIDtn8NFRhNkTZPR+ZWM=.

If you get a different value, the usual causes are, in order of likelihood: the body was re-serialized before hashing rather than hashed raw, the method was not uppercased, the query string was dropped from the path, or the lines were joined with \r\n.

Signing is one of three things that decide whether traffic reaching your origin can be trusted. The other two are not GraphPilot settings: the connection itself, which is encrypted only when your origin URL is https:// (see TLS and ports), and who may connect at all, which your firewall decides (see the origin allowlist). An allowlist admits the whole edge network; only the signature proves a request came through your service.

A signature alone does not stop someone from capturing a valid request and sending it again; the timestamp closes that gap. Reject any request whose GP-Timestamp is more than 5 minutes (300 seconds) away from your origin’s current time, in either direction, and keep your origin’s clock in sync (NTP) so legitimate requests are not rejected.

For stricter protection, also track the signatures (or a nonce) you have already accepted within the window and reject duplicates. The timestamp window bounds how much state you need to keep.

Rotate the key periodically, immediately if it may have been exposed, and to replace the key you were shown at creation if you did not keep it. The new key is shown in full exactly once, like the first.

GraphPilot signs with exactly one active key at a time, so you control the cutover: a new key is created pending, you deploy it to your origin, and only then do you activate it.

  1. In Organization → Service → Settings → General, scroll to the Danger zone and, under Signing key, choose Generate a new pending key. This creates a new key in a pending state. GraphPilot keeps signing with the current active key, so nothing changes yet. The pending key is shown in full, once, in the dialog that follows. Copy it there: from then on it is masked like every other key, and GraphPilot cannot show it to you again. If you lose it before step 2, choose Cancel rotation, which discards the pending key and changes nothing else, and then generate another one: while a pending key exists, that is the only way to get a fresh one.

  2. Add the pending key to your origin so it accepts a request that verifies against either the active key or the pending key. Deploy this change.

  3. Back in the portal, choose Activate pending key. The dialog asks you to tick that your origin already accepts the pending key before it will let you go through with it. GraphPilot then signs every request with the new key, and because your origin already accepts it, no request is rejected during the switch.

  4. Once the change has propagated and any in-flight requests signed with the old key have drained (a few minutes, comfortably within the replay window), remove the old key from your origin. The rotation is complete.

The only moment your origin needs to accept two keys is the window between step 2 and step 4. Outside of it, a single key is active and accepted on both sides.

  • Verify on every request and reject anything that does not verify, instead of logging and continuing.
  • Use a constant-time comparison, and hash the raw request body.
  • Stage the pending key on your origin before activating it.