Skip to main content

Request and Response Signatures

Overview

Request and response signatures help protect Stitch API bodies from tampering. When this feature is enabled, your integration signs each API request, and Stitch signs each API response.

Both signatures use JWS Compact Serialization with an unencoded, detached payload. Request signatures are sent in the Client-Signature API header, and response signatures are sent in the Stitch-Signature API header. Both use PS512.

Enable signatures

Request and response signatures must be explicitly enabled for your client. Contact support@stitch.money to learn more.

Signature enforcement

When request and response signatures are enabled for your client, every API request must include a valid Client-Signature header. Stitch rejects requests and returns an error when the Client-Signature header is missing, malformed, uses an unsupported algorithm, is signed with an unknown key, has an invalid signature, or does not match the request body.

Stitch signs every API response with the Stitch-Signature header. If your integration cannot verify the response signature, treat the response as failed and do not trust the response body.

Before you start

Request and response signatures use asymmetric key pairs. Your integration signs requests with its private key, and Stitch verifies those requests with the corresponding public key. Stitch signs responses with its private key, and your integration verifies those responses with Stitch's public keys.

Before enabling signatures, generate an RSA key pair with a modulus of at least 2048 bits and provide the public key details to Stitch during onboarding. Each request-signing key must have a key ID (kid), which identifies the key used to generate or verify a signature. Your integration defines the kid; use a stable value that is unique across your active and recently rotated signing keys.

Your integration should use the active private key and kid when signing requests. Do not share your private key with Stitch or any system that does not need to sign requests.

Signature format

Both signature headers contain a compact JWS with a detached payload. A compact JWS has three period-separated segments. The HTTP body is the JWS payload, but it is not included in the header value, so the middle segment is empty:

Client-Signature: <base64url(protected-header)>..<base64url(signature)>
Stitch-Signature: <base64url(protected-header)>..<base64url(signature)>

The JWS protected header must contain:

{
"alg": "PS512",
"kid": "key-id",
"b64": false,
"crit": ["b64"]
}

Setting b64 to false means that the JWS signing input contains the body bytes directly, without base64url-encoding them.

Use these rules when signing or verifying a body:

  • alg must be PS512.
  • kid identifies the private key used to sign the body and the public key used to verify it.
  • b64 must be false, and crit must contain only b64.
  • Sign the exact uncompressed application body bytes. For requests, these are the bytes supplied to the HTTP client before HTTP content encoding. For responses, verify the decoded bytes returned by the HTTP client after HTTP content encoding has been removed.
  • Do not parse, reformat, or re-encode the body before verification. Even a whitespace change produces a different signature.
  • For a request or response with no body, sign an empty byte sequence.

Client public key format

Your public signing key must be represented as an RSA JWK:

{
"kty": "RSA",
"kid": "key-id",
"use": "sig",
"alg": "PS512",
"n": "base64url-encoded-modulus",
"e": "AQAB"
}

Stitch public keys

Stitch publishes its public signing keys in JWKS format:

GET https://api.stitch.money/.well-known/jwks.json
{
"keys": [
{
"kty": "RSA",
"kid": "stitch-signing-8ac7323838e6",
"use": "sig",
"alg": "PS512",
"n": "base64url-encoded-modulus",
"e": "AQAB"
}
]
}

Stitch currently signs API responses with the private key corresponding to the public JWKS key whose kid is stitch-signing-8ac7323838e6.

Create a request signature

Generate a signing key pair

Use a secure key management system that can generate an RSA key pair with a modulus of at least 2048 bits. For local setup, OpenSSL can generate a PKCS#8 private key that matches the Node.js signing example below:

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out client-signing-private-key.pem
openssl pkey -in client-signing-private-key.pem -pubout -out client-signing-public-key.pem

Store the private key securely and use it only when signing requests. Share the public key details with Stitch during onboarding, including the same kid your integration will use in request signatures.

Create the request body bytes once, sign those bytes, and send the same bytes as the HTTP body. Do not sign a parsed JSON object and then serialize it separately, as even small serialization differences produce an invalid signature. Include the detached compact JWS in the Client-Signature request header.

curl --request POST 'https://api.stitch.money/graphql' \
--header 'Authorization: Bearer <client_access_token>' \
--header 'Content-Type: application/json' \
--header 'Client-Signature: <detached_compact_jws>' \
--data '{"query":"query Ping { client { id } }"}'
import { CompactSign, importPKCS8 } from "jose";

const keyId = process.env.CLIENT_SIGNATURE_KEY_ID;
const privateKeyPem = process.env.CLIENT_SIGNATURE_PRIVATE_KEY_PEM;

async function loadPrivateKey() {
if (!privateKeyPem) {
throw new Error("Missing CLIENT_SIGNATURE_PRIVATE_KEY_PEM");
}

return importPKCS8(privateKeyPem.replace(/\\n/g, "\n"), "PS512");
}

async function createRequestSignature({ rawBody }) {
if (!keyId) {
throw new Error("Missing CLIENT_SIGNATURE_KEY_ID");
}

const privateKey = await loadPrivateKey();

return new CompactSign(rawBody)
.setProtectedHeader({
alg: "PS512",
kid: keyId,
b64: false,
crit: ["b64"],
})
.sign(privateKey);
}

const url = "https://api.stitch.money/graphql";
const method = "POST";
const body = JSON.stringify({
query: "query Ping { client { id } }",
});
const rawBody = Buffer.from(body, "utf8");

const signature = await createRequestSignature({ rawBody });

const response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${process.env.STITCH_CLIENT_ACCESS_TOKEN}`,
"Content-Type": "application/json",
"Client-Signature": signature,
},
body: rawBody,
});

Verifying responses

Verify the Stitch-Signature response header before parsing or trusting the response body. Stitch signs the uncompressed application response bytes before HTTP content encoding is applied. Verify the decoded bytes returned by your HTTP client after it removes any HTTP content encoding.

Use the kid in the JWS protected header to select the correct Stitch public key from the JWKS. Stitch response signatures currently use kid stitch-signing-8ac7323838e6.

For a valid response signature:

  • The compact JWS must have exactly three segments, and the detached payload segment must be empty.
  • The JWS must be signed with PS512.
  • The JWS must use a known kid from Stitch's JWKS.
  • The protected header must contain b64: false and crit: ["b64"].
  • The signature must verify against the exact decoded application response bytes.
import {
createRemoteJWKSet,
decodeProtectedHeader,
flattenedVerify,
} from "jose";

const stitchJwks = createRemoteJWKSet(
new URL("https://api.stitch.money/.well-known/jwks.json"),
);

function parseDetachedCompactJws(signature) {
const segments = signature.split(".");

if (segments.length !== 3) {
throw new Error("Invalid Stitch response signature format");
}

const [protectedHeader, payload, encodedSignature] = segments;

if (!protectedHeader || !encodedSignature) {
throw new Error("Invalid Stitch response signature format");
}

if (payload !== "") {
throw new Error("Stitch response signature payload is not detached");
}

return {
protected: protectedHeader,
signature: encodedSignature,
};
}

async function verifyResponseSignature({ signature, rawBody }) {
if (!signature) {
throw new Error("Missing Stitch response signature");
}

const detachedJws = parseDetachedCompactJws(signature);
const header = decodeProtectedHeader(signature);

if (header.alg !== "PS512") {
throw new Error("Unsupported Stitch signature algorithm");
}

if (typeof header.kid !== "string" || header.kid.length === 0) {
throw new Error("Missing Stitch signature key ID");
}

if (
header.b64 !== false ||
!Array.isArray(header.crit) ||
header.crit.length !== 1 ||
header.crit[0] !== "b64"
) {
throw new Error("Unsupported Stitch signature payload encoding");
}

const { protectedHeader } = await flattenedVerify(
{
...detachedJws,
payload: rawBody,
},
stitchJwks,
{
algorithms: ["PS512"],
},
);

return protectedHeader;
}

const responseBody = Buffer.from(await response.arrayBuffer());
const responseSignature = response.headers.get("Stitch-Signature");

await verifyResponseSignature({
signature: responseSignature,
rawBody: responseBody,
});