Short answer. All three services keep the private key in their HSMs or software modules and sign what you send them. The differences that break integrations are small and specific. AWS KMS and Google Cloud KMS return ECDSA signatures DER-encoded; a JWT needs the 64-byte r ‖ s form, so convert before you base64url-encode. Azure Key Vault returns r ‖ s already. Send a digest for anything larger than a few kilobytes, and tell the service it is a digest (MessageType: DIGEST in AWS, the digest field in Google Cloud; Azure always expects one), or it will be hashed twice. Grant callers a sign-only permission on one key, turn on the audit logs that are off by default (Google Cloud Data Access logs, Azure diagnostic settings), and verify signatures offline with the public key instead of calling the service.
The cloud calls on this page could not run in a container. Their code was type-checked with tsc --strict against the pinned SDKs, and every format problem was reproduced with a local key that behaves like the service: same hash, same output encoding.
The three services side by side
| AWS KMS | Google Cloud KMS | Azure Key Vault | |
|---|---|---|---|
| Signing call | Sign | AsymmetricSign | sign (REST keys/{name}/{version}/sign) |
| Input | message up to 4096 bytes, or digest (MessageType RAW, DIGEST, EXTERNAL_MU) | digest or data, never both | always a digest |
| ECDSA output | DER (RFC 3279) | DER | r ‖ s (RFC 7518 format) |
| EC keys | P-256, P-384, P-521, secp256k1 | P-256, P-384, secp256k1 (HSM only) | P-256, P-256K (secp256k1), P-384, P-521 |
| Ed25519 | yes, pure and prehashed | yes, pure (software keys only) | no |
| RSA | 2048, 3072, 4096; PSS and PKCS #1 v1.5 | 2048, 3072, 4096; PSS, PKCS #1 v1.5, raw PKCS #1 | 2048, 3072, 4096; PSS and PKCS #1 v1.5 |
| Post-quantum | ML-DSA-44/65/87 | ML-DSA-44/65/87, SLH-DSA-SHA2-128s (software keys) | none |
| Key protection | FIPS 140-3 Level 3 HSMs | software (FIPS 140-3 L1 primitives) or Cloud HSM (FIPS 140-2 L3) | software (FIPS 140-2 L1) or HSM (FIPS 140-3 L3 on HSM platform 2, Premium) |
Sources: AWS key spec reference and Sign; Google Cloud algorithms and protection levels; Azure key types and algorithms. Two details from those pages matter later: Google Cloud KMS "generate[s] signatures in the 'normalized' form only (also known as the 'lower-S form')" for secp256k1, and AWS KMS documents no such guarantee.
Digest or message
ECDSA, RSA and ML-DSA signatures start by hashing the input. A KMS can do that for you, but AWS accepts at most 4096 bytes of message and Azure accepts only digests, so in practice you hash locally and send the 32-byte SHA-256 value. The service then has to skip its own hashing step, and you have to say so:
- AWS KMS:
MessageType: "DIGEST". The API reference warns that sending a digest asRAW"can cause verification failures when verifying with a system that assumes a single hash". - Google Cloud KMS: put the value in
digest.sha256(orsha384, matching the key's algorithm). Thedatafield is required for raw-input algorithms such as Ed25519. - Azure Key Vault: the operation is "Creates a signature from a digest";
ES256expects a 32-byte SHA-256 value.
What the double-hash mistake looks like, reproduced with OpenSSL 3.6.5 and a local P-256 key in place of the KMS key:
openssl dgst -sha256 -binary release.tar.gz > release.sha256
openssl pkeyutl -sign -inkey kms.key -in release.sha256 -out release.sig # the KMS call
openssl dgst -sha256 -verify kms.pub -signature release.sig release.tar.gz
# The digest sent as if it were the message (MessageType RAW): hashed twice
openssl dgst -sha256 -sign kms.key -out double.sig release.sha256
openssl dgst -sha256 -verify kms.pub -signature double.sig release.tar.gz
openssl dgst -sha256 -verify kms.pub -signature double.sig release.sha256
Verified OK
Verification failure
Verified OK
The second signature is valid, just not over the file: it signs the digest file as a message. Every standard verifier hashes the file once, so it fails. The fix is a single parameter, which is why this bug survives code review.
Ed25519 has its own variant of the problem. AWS KMS offers two algorithms for ECC_NIST_EDWARDS25519 keys: ED25519_SHA_512 (pure Ed25519, requires MessageType: RAW, so at most 4096 bytes) and ED25519_PH_SHA_512 (Ed25519ph from RFC 8032, requires DIGEST, and AWS still applies its own SHA-512 prehash, "the input is hashed twice: once by you and once by KMS"). The two produce different signatures that do not verify as each other:
pure signature, verified as Ed25519 Signature Verified Successfully
Ed25519ph signature, verified as Ed25519 Signature Verification Failure
Ed25519ph signature, verified as Ed25519ph Signature Verified Successfully
Most Ed25519 verifiers (JOSE EdDSA, SSH, minisign) implement pure Ed25519 only. Use ED25519_SHA_512 with small inputs such as a JWT signing input, and for files sign a manifest that contains the file's hash.
Signing JWTs: DER versus r ‖ s
A JWS with alg: ES256 carries a signature of exactly 64 bytes, r and s each left-padded to 32 bytes (RFC 7518 §3.4). AWS and Google Cloud return the DER SEQUENCE { INTEGER r, INTEGER s } instead. The lab signs a JWT with a local P-256 key that returns DER, exactly like those services, and verifies it with jose and jsonwebtoken before and after conversion:
/** DER ECDSA-Sig-Value (RFC 3279) -> fixed-size r || s (RFC 7518 §3.4). */
function derToJose(der, size = 32) {
let i = 0;
const take = (buf, tag) => {
if (buf[i++] !== tag) throw new Error(`expected ASN.1 tag 0x${tag.toString(16)}`);
let len = buf[i++];
if (len & 0x80) { // long form length
const n = len & 0x7f;
len = 0;
for (let k = 0; k < n; k++) len = (len << 8) | buf[i++];
}
const value = buf.subarray(i, i + len);
i += len;
return value;
};
const seq = take(der, 0x30);
if (i !== der.length) throw new Error("trailing bytes after the signature");
i = 0;
const out = Buffer.alloc(2 * size);
for (const offset of [0, size]) {
let int = take(seq, 0x02);
while (int.length > size && int[0] === 0) int = int.subarray(1); // drop the sign byte
if (int.length > size) throw new Error("integer longer than the curve size");
int.copy(out, offset + size - int.length); // left-pad short values with zeros
}
return out;
}
const b64u = (b) => Buffer.from(b).toString("base64url");
async function signJwt(payload, { convert }) {
const input = `${b64u(JSON.stringify({ alg: "ES256", typ: "JWT", kid: "kms-key-1" }))}.${b64u(JSON.stringify(payload))}`;
const der = await kmsSignDer(Buffer.from(input));
return `${input}.${b64u(convert ? derToJose(der) : der)}`;
}
node v24.22.0, OpenSSL 3.5.8, jose 6.2.13, jsonwebtoken 9.0.3
DER as returned (72 bytes) jose: ERR_JWS_SIGNATURE_VERIFICATION_FAILED | jsonwebtoken: TypeError: "ES256" signatures must be "64" bytes, saw "72"
converted to r||s (64 bytes) jose: valid | jsonwebtoken: valid
DER lengths over 20000 signatures: {"68":1,"69":45,"70":4965,"71":9956,"72":5033}
r || s without left padding would not be 64 bytes in 172 of 20000 (about 1 in 116)
kmsSignDer here is sign("sha256", data, privateKey) from node:crypto, which returns DER like the services. Three things are worth remembering:
- jose rejects DER without a hint about the format; jsonwebtoken names it: ES256 signatures must be 64 bytes. Java's verifiers say Invalid encoding for signature when handed the opposite format.
- DER length varies from 68 to 72 bytes. Code that slices a fixed offset, or assumes 70 bytes, works in testing and fails in production.
- About one signature in 128 has an
rorsshorter than 32 bytes (172 of 20,000 in this run). A converter that forgets to left-pad produces a 63-byte signature that some verifiers reject and others misread, the kind of intermittent bug that appears as "one login in a hundred fails". ECDSA signature formats covers the encodings in detail.
For Azure Key Vault, the bytes in SignResult.result are already r ‖ s and go straight into the JWS. The opposite conversion is then needed for verifiers that expect DER, such as Java's SHA256withECDSA or OpenSSL.
The kid should name the key version, not just the key, so that verifiers can pick the right public key during a rotation. Publish the public key as a JWK: AWS GetPublicKey and Google Cloud GetPublicKey return SPKI (DER or PEM), Azure returns a JWK. Rotation without breaking verifiers is the subject of JWKS key rotation.
Calling each service
The three functions below sign the SHA-256 digest of some data and return the bytes each service gives back. They were type-checked with tsc --strict (TypeScript 5.9.3) against @aws-sdk/client-kms 3.1153.0, @google-cloud/kms 6.3.0, @azure/keyvault-keys 4.10.2 and @azure/identity 4.14.0; they were not run against the services, so no output is shown.
// Signing a SHA-256 digest with each cloud's key service. Each returns the raw bytes the service gives back.
import { createHash } from "node:crypto";
import { GetPublicKeyCommand, KMSClient, SignCommand } from "@aws-sdk/client-kms";
import { KeyManagementServiceClient } from "@google-cloud/kms";
import { CryptographyClient } from "@azure/keyvault-keys";
import { DefaultAzureCredential } from "@azure/identity";
const sha256 = (data: Uint8Array) => createHash("sha256").update(data).digest();
// AWS KMS: ECC_NIST_P256 key, ECDSA_SHA_256. Returns DER (RFC 3279).
const aws = new KMSClient({});
export async function signWithAws(keyId: string, data: Uint8Array): Promise<Uint8Array> {
const out = await aws.send(
new SignCommand({ KeyId: keyId, Message: sha256(data), MessageType: "DIGEST", SigningAlgorithm: "ECDSA_SHA_256" }),
);
if (!out.Signature) throw new Error("AWS KMS returned no signature");
return out.Signature;
}
export async function publicKeyFromAws(keyId: string): Promise<Uint8Array> {
const out = await aws.send(new GetPublicKeyCommand({ KeyId: keyId }));
if (!out.PublicKey) throw new Error("AWS KMS returned no public key");
return out.PublicKey; // DER SubjectPublicKeyInfo
}
// Google Cloud KMS: EC_SIGN_P256_SHA256 key version. Returns DER; CRC32C guards the request and response.
const gcp = new KeyManagementServiceClient();
export async function signWithGoogle(versionName: string, data: Uint8Array): Promise<Uint8Array> {
const digest = sha256(data);
const [res] = await gcp.asymmetricSign({ name: versionName, digest: { sha256: digest }, digestCrc32c: { value: crc32c(digest) } });
if (res.name !== versionName || !res.verifiedDigestCrc32c) throw new Error("request corrupted in transit");
const signature = Buffer.from(res.signature as Uint8Array);
if (crc32c(signature) !== Number(res.signatureCrc32c?.value)) throw new Error("response corrupted in transit");
return signature;
}
// Azure Key Vault: EC P-256 key, ES256. Returns r || s (64 bytes), the JWS format.
export async function signWithAzure(keyId: string, data: Uint8Array): Promise<Uint8Array> {
const client = new CryptographyClient(keyId, new DefaultAzureCredential());
const { result } = await client.sign("ES256", sha256(data));
return result;
}
/** CRC32C (Castagnoli), as Cloud KMS uses for integrity fields. */
function crc32c(bytes: Uint8Array): number {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let k = 0; k < 8; k++) crc = crc & 1 ? (crc >>> 1) ^ 0x82f63b78 : crc >>> 1;
}
return (crc ^ 0xffffffff) >>> 0;
}
The CRC32C helper returned 0xe3069283 for the input "123456789", the published check value of CRC-32C. Google Cloud's signing guide recommends the three checks shown: verifiedDigestCrc32c says the request arrived intact, name that the right key version signed, and signatureCrc32c that the response arrived intact.
Azure's raw output is the format of the Windows CNG APIs underneath; the Azure SDK team described it in 2018 as "'raw' format (the same used by Windows bcrypt and ncrypt APIs)" (azure-keyvault-java #58). That is convenient for JOSE and inconvenient for everything that speaks X.509.
Verify locally, not with the service
All three services offer a Verify call, and all three let you download the public key. Prefer the second: verifying with the public key costs nothing, has no quota and no network dependency, and it is what your relying parties will do anyway. If your own test only passes through the service's Verify, it can hide a format mismatch that every other verifier sees. AWS says the same about its kms:SigningAlgorithm condition key: it "has no effect on operations performed outside of AWS KMS, such as verifying signatures with the public key".
Quotas are another reason. AWS KMS allows 1,000 requests per second, shared by Sign and Verify across all ECC keys of an account and Region, and the same again for RSA and for ML-DSA keys (request quotas, adjustable). A service that signs every session token through KMS hits that ceiling sooner than one that signs a few releases a day; sign short-lived data keys or batch where you can.
Permissions: one key, sign only
The useful unit of permission is "this workload may sign with this key", nothing more:
- AWS KMS: allow
kms:Signon the key's ARN, in the key policy or an IAM policy, and restrict it with condition keys:kms:SigningAlgorithmpins the algorithm andkms:MessageTypecan forbidDIGESTwhen only small messages should be signed (a digest lets the caller sign any data).kms:GetPublicKeycan be granted separately to verifiers. Cross-account use is possible with the key ARN. - Google Cloud KMS: grant
roles/cloudkms.signer("Enables Sign operations", permissioncloudkms.cryptoKeyVersions.useToSign) on the single CryptoKey, androles/cloudkms.publicKeyViewerto whoever only fetches the public key.roles/cloudkms.signerVerifiercombines sign, verify and public key access. - Azure Key Vault: with Azure RBAC (the default for vaults created with API version 2026-02-01 and later), "Key Vault Crypto User" allows cryptographic operations with keys, "Key Vault Crypto Officer" also manages them. For sign only, create a custom role with the data action
Microsoft.KeyVault/vaults/keys/sign/action; the built-in Crypto User role also encrypts, decrypts and wraps. Microsoft recommends one vault per application and environment.
A key that signs releases, a key that signs tokens and a key that signs webhooks should be three keys, each with its own principals. Rotation then affects one consumer at a time.
Audit logs are not always on
A signing key in a cloud service is only as accountable as the log of its use:
- AWS CloudTrail records KMS calls, including
Sign, as management events by default; the log entry has the key, caller, algorithm and message type but not the message or the signature. A trail can exclude KMS events entirely (ExcludeManagementEventSources); look for aPutEventSelectorsevent if KMS entries are missing. - Google Cloud classifies
AsymmetricSignas a Data Access (DATA_READ) operation, and "Data Access audit logs are disabled by default for all services but some BigQuery services" (Cloud Logging). Without enabling them for Cloud KMS, you have no record of signatures. - Azure Key Vault writes
AuditEventrecords (operation nameKeySign) only after you configure diagnostic settings to send them to a storage account or Log Analytics workspace.
This is not only good practice for code signing: since June 2023 the CA/Browser Forum's Code Signing Baseline Requirements (§6.2.7.4.1) allow a cloud key service for publicly trusted code-signing keys only if the subscription is "configured to log all access, operations, and configuration changes on the resources securing the Private Key". Authenticode code signing covers that use case.
Common mistakes
- DER in a JWT, or
r ‖ shanded to a DER verifier. Check the length: 64 bytes for ES256, 68 to 72 bytes starting with0x30for DER. The DER to raw converter converts a single signature both ways. - Unpadded conversion that works for 99% of signatures.
- Double hashing: a digest sent as a message.
- Ed25519ph where Ed25519 was expected, or the reverse.
- High-S signatures on blockchains. ECDSA signatures from AWS KMS secp256k1 keys need low-S normalization and a recovery id before Ethereum accepts them; ECDSA public key recovery shows how.
- Verifying through the service in tests only. Download the public key and verify the way a relying party will.
- Broad roles and missing logs. A role that can sign with every key in a project, and no Data Access log, leaves no trace of what was signed.
- Assuming a key can move. Keys generated in these services are not exportable; a migration to another provider means a new key and a published rotation.
Sources
- AWS KMS:
SignAPI reference, key spec reference, condition keys, request quotas, CloudTrail logging - Google Cloud KMS: algorithms, creating and validating signatures, roles, audit logging
- Azure Key Vault: key types and algorithms,
signREST API, Azure RBAC, logging - RFC 7518 §3.4 (ECDSA in JWS), RFC 3279 §2.2.3 (ECDSA in DER), RFC 8032 §5.1 (Ed25519 and Ed25519ph)
- CA/Browser Forum Code Signing Baseline Requirements 3.11.0, §6.2.7.4