Signing Worker Protocol

Authentication

How your signing worker verifies that each SWP/1 request comes from SignatureAPI, for an account it serves, unchanged

Private beta. Qualified signatures are available on request. Request access and we enable them for your account. SWP/1 is in beta and may change.

Your worker signs in your signers’ names, so it must accept requests from SignatureAPI only, and only for the accounts it serves. Every request carries a short-lived token that SignatureAPI signs for that one request. Your worker verifies it with SignatureAPI’s public keys. There are no shared secrets to store or rotate.

Request headers

Every operation is a POST to {worker URL}/v1/{operation} with a JSON body and these headers:

HeaderValue
AuthorizationBearer <token>. See The token.
Content-Digestsha-256=:<base64 SHA-256 of the raw body>:
Idempotency-Key<session.id>/<operation>/<session.attempt>, plus /resume when the request carries callback.code. See Idempotency.
SignatureAPI-Protocolswp/1

GET /v1/health carries no token and needs no authentication.

The token

The token is a JWT signed with ES256. Any JWT library can verify it.

Claim or headerValueWhat your worker does
alg, kidES256, key ID in the headerAccept only ES256. Look up kid in the key set.
isshttps://api.signatureapi.comVerify it exactly.
audThe worker URL as connected, without a trailing slashVerify it exactly. A token for another worker must not verify at yours.
subThe signer’s recipient IDOptionally compare it with signer.id.
iat, expexp is 5 minutes after iatReject expired tokens. Allow a little clock skew.
jtiUnique per requestReject one you have already accepted. See Replays.
connection_tokenThe calling account’s connection token for the session’s modeRefuse it unless it is on your list.
swp.operationThe operation nameCompare it with the path.
swp.sessionThe session IDCompare it with session.id in the body.
swp.body_sha256Base64 SHA-256 of the raw bodyCompare it with the bytes you received, before parsing them.

A decoded token:

{
    "iss": "https://api.signatureapi.com",
    "aud": "https://signer.example.com",
    "sub": "re_00ZeZjRaXCgT30n1eBx53N",
    "iat": 1787590800,
    "exp": 1787591100,
    "jti": "5b0f3c2e-8d1a-4f6b-9c7e-2a3d4e5f6a7b",
    "connection_token": "swpct_xulVJ9y9r1RWzRVwMZSzC4AMxZPCvI7W",
    "swp": {
        "session": "sgs_YFobK1NDktAuz0wzplR4OP",
        "operation": "credential",
        "body_sha256": "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
    }
}

Connection tokens

A connection token identifies one SignatureAPI account in one mode, without revealing anything about the account. It starts with swpct_. An account’s test and live tokens differ, so a test token never authorizes live signing.

Account owners find their tokens in the Dashboard and send them to you. Keep a list of the tokens you accept, list them in your health response, and refuse any request whose connection_token isn’t on the list:

{ "error": "invalid_token", "error_description": "connection token not allowed" }

Without this check, any SignatureAPI account that enters your URL could send signing requests to your worker.

Public keys

SignatureAPI publishes its public keys as a JSON Web Key Set at https://api.signatureapi.com/v1/signing-protocol/jwks. It needs no authentication.

{
    "keys": [
        {
            "kty": "EC",
            "crv": "P-256",
            "x": "out19VTBbggxibTqZoC-3LL3QU8sJZOt_HEQ5DH-FHg",
            "y": "tQOFrfPVCp-tzwhY_0KN1VL2OeXePqJ6RrilsE13qJU",
            "kid": "WyPngh2UWAaPvkD8",
            "use": "sig",
            "alg": "ES256"
        }
    ]
}

Cache the set, and fetch it again when a token has an unknown kid. Don’t hard-code a key.

Verify a request

Check in this order, and stop at the first failure:

  1. Protocol. SignatureAPI-Protocol is swp/1. If not, answer 400.
  2. Token. Verify the signature, alg, iss, aud, iat and exp.
  3. Bindings. swp.operation matches the path, swp.body_sha256 matches the SHA-256 of the raw body, and swp.session matches session.id.
  4. Connection token. connection_token is on your list.
  5. Replay. You haven’t accepted this jti before. Remember it now.
  6. Content-Digest. The header matches the same hash. If not, answer 400.
  7. Idempotency-Key. It is present. If not, answer 400 with error_description missing Idempotency-Key.

Refuse a failure in steps 2 to 5 with 401, "error": "invalid_token", and the failing check in error_description. The Dashboard shows that text when a connection check fails.

The order matters. A request without an Idempotency-Key is still authenticated first. The connection checks send exactly such a request and expect 400, and they send a token for another worker and expect 401.

Example in Node.js

This example uses the jose library, whose key set helper caches keys and refetches on an unknown kid.

import { createHash } from "node:crypto";
import { createRemoteJWKSet, jwtVerify } from "jose";

const WORKER_URL = "https://signer.example.com"; // Exactly the URL the account connects
const CLOCK_TOLERANCE_S = 60;
// Comma-separated swpct_ tokens of the accounts this worker serves
const ALLOWED_CONNECTION_TOKENS = new Set(
    (process.env.CONNECTION_TOKENS ?? "").split(",").map((t) => t.trim()).filter(Boolean),
);
const JWKS = createRemoteJWKSet(new URL("https://api.signatureapi.com/v1/signing-protocol/jwks"));
const seenJtis = new Map(); // jti -> forget-after (ms). Use a shared store across instances.

const refuse = (status, error, error_description) => ({ status, error, error_description });

// rawBody is the request body as received (a Buffer), before JSON parsing
export async function authenticate(headers, rawBody, operation) {
    if (headers["signatureapi-protocol"] !== "swp/1") {
        return refuse(400, "invalid_request", "missing SignatureAPI-Protocol");
    }

    const token = (headers["authorization"] ?? "").replace(/^Bearer /, "");
    let claims;
    try {
        ({ payload: claims } = await jwtVerify(token, JWKS, {
            algorithms: ["ES256"],
            issuer: "https://api.signatureapi.com",
            audience: WORKER_URL,
            requiredClaims: ["iat", "exp", "jti"],
            clockTolerance: CLOCK_TOLERANCE_S,
            maxTokenAge: "5m",
        }));
    } catch (error) {
        return refuse(401, "invalid_token", error.message);
    }

    const bodySha256 = createHash("sha256").update(rawBody).digest("base64");
    if (claims.swp?.operation !== operation) {
        return refuse(401, "invalid_token", "operation mismatch");
    }
    if (claims.swp?.body_sha256 !== bodySha256) {
        return refuse(401, "invalid_token", "body_sha256 mismatch");
    }
    let body;
    try {
        body = JSON.parse(rawBody);
    } catch {
        return refuse(400, "invalid_request", "body is not JSON");
    }
    if (claims.swp?.session !== body.session?.id) {
        return refuse(401, "invalid_token", "session mismatch");
    }
    if (!ALLOWED_CONNECTION_TOKENS.has(claims.connection_token)) {
        return refuse(401, "invalid_token", "connection token not allowed");
    }

    const now = Date.now();
    if ((seenJtis.get(claims.jti) ?? 0) > now) {
        return refuse(401, "invalid_token", "replayed jti");
    }
    // Remember the jti for as long as the token could still pass the exp check
    seenJtis.set(claims.jti, (claims.exp + CLOCK_TOLERANCE_S) * 1000);

    if (headers["content-digest"] !== `sha-256=:${bodySha256}:`) {
        return refuse(400, "invalid_request", "Content-Digest mismatch");
    }
    if (!headers["idempotency-key"]) {
        return refuse(400, "invalid_request", "missing Idempotency-Key");
    }

    return { body, bodySha256, idempotencyKey: headers["idempotency-key"] };
}

Replays

Remember each accepted jti until the token’s exp plus your clock tolerance. After that, the token fails the exp check anyway. Record a jti only once the request passes every token check, so a rejected request can’t use up a legitimate one.

Each HTTP attempt carries a new token. When SignatureAPI retries a call, it signs a new token with a new jti over the same body, with the same Content-Digest and Idempotency-Key. Your jti check stops replays. Your idempotency handling stops a retry from running the act twice.

Responses are not signed

Your responses need no signature. A returned signature either verifies over the digest SignatureAPI sent, against the certificate it already holds, or the session fails. See What SignatureAPI checks.