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.
The signing key
Section titled “The signing key”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 dialog that shows it
Section titled “The dialog that shows it”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.
How GraphPilot signs a request
Section titled “How GraphPilot signs a request”GraphPilot adds two headers to each forwarded request:
GP-Timestamp: 1718700000GP-Signature: v1=Base64(HMAC-SHA256(signing_key, canonical_string))GP-Timestampis the time the request was signed, as Unix seconds. It is part of the signed data and drives replay protection.GP-Signaturecarries 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 canonical string
Section titled “The canonical string”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}| Field | Description |
|---|---|
HTTP_METHOD | The request method, uppercased (e.g. POST, or QUERY if your service forwards reads as QUERY). |
PATH_ | The request target as sent to your origin: the path including the query string (e.g. /). |
TIMESTAMP | The same value as the GP-Timestamp header. |
BODY_HASH | Lowercase hex of SHA-. 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.
What the signature does not cover
Section titled “What the signature does not cover”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. Withorigin.forward_client_ipon, 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.
Verify the signature on your origin
Section titled “Verify the signature on your origin”Reject any request that fails verification. The steps:
-
Read
GP-TimestampandGP-Signature. If either is missing, reject the request. -
Check the timestamp against your replay window. If it is too old or too far in the future, reject the request.
-
Rebuild the canonical string from the request, using the raw request body (the exact bytes received, before any JSON parsing or re-serialization).
-
Compute
Base64(HMAC-SHA256(signing_key, canonical_string)). -
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)}import base64import hashlibimport hmacimport time
TOLERANCE_SECONDS = 300
# `raw_body` must be the exact bytes received, not a re-serialized object.def verify_graphpilot_signature(method, path_and_query, raw_body, headers, signing_key): timestamp = headers.get("GP-Timestamp") signature_header = headers.get("GP-Signature") if not timestamp or not signature_header: return False
# Reject stale or future timestamps. try: age = abs(int(time.time()) - int(timestamp)) except ValueError: return False if age > TOLERANCE_SECONDS: return False
body_hash = hashlib.sha256(raw_body).hexdigest() canonical = "\n".join([method.upper(), path_and_query, timestamp, body_hash]) expected = base64.b64encode( hmac.new(signing_key.encode(), canonical.encode(), hashlib.sha256).digest() ).decode()
# Header looks like "v1=<base64>". provided = signature_header.removeprefix("v1=") return hmac.compare_digest(expected, provided)package graphpilot
import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/hex" "net/http" "strconv" "strings" "time")
const toleranceSeconds = 300
// rawBody must be the exact bytes received, read before any parsing.func VerifyGraphPilotSignature(method, pathAndQuery string, rawBody []byte, headers http.Header, signingKey string) bool { timestamp := headers.Get("GP-Timestamp") signatureHeader := headers.Get("GP-Signature") if timestamp == "" || signatureHeader == "" { return false }
// Reject stale or future timestamps. ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return false } age := time.Now().Unix() - ts if age < 0 { age = -age } if age > toleranceSeconds { return false }
bodyHash := sha256.Sum256(rawBody) canonical := strings.Join([]string{ strings.ToUpper(method), pathAndQuery, timestamp, hex.EncodeToString(bodyHash[:]), }, "\n") mac := hmac.New(sha256.New, []byte(signingKey)) mac.Write([]byte(canonical)) expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
// Header looks like "v1=<base64>". provided := strings.TrimPrefix(signatureHeader, "v1=") return hmac.Equal([]byte(expected), []byte(provided))}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.
gpsk_test_key{"query":"{ me { id } }"}POST/graphql?op=ping171870000064d4ae404c393f3916a308845f1df6e2ccd4243d889d3883f9b2dd7da9d31114708YYmz8ifG4D6z8Fl1XVh1oIDtn8NFRhNkTZPR+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.
Trust and transport
Section titled “Trust and transport”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.
Replay protection
Section titled “Replay protection”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.
Rotating the signing key
Section titled “Rotating the signing key”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.
-
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.
-
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.
-
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.
-
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.
Best practices
Section titled “Best practices”- 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.
Related
Section titled “Related”- Origin allowlist to refuse traffic that did not come from the edge network.
- Authentication for verifying your users’ tokens at the edge.
- Hardening a public API for the other controls GraphPilot applies.