Skip to main content

Overview

An identity signing key is an ECDSA P-256 public key that you register for one of your identities. Whoever holds the matching private key, the signer, must sign each sensitive request for that identity. Bastion rejects the request if the signature is missing or does not match.
  • Keys belong to a single identity.
  • An identity can have more than one key. A signature from any of them is accepted.
  • The signature covers the exact operation, such as the amount and destination of a transfer, so it cannot be reused for a different one.
A signing key adds a second check on top of your API key. A request with a valid API key but no valid signature is rejected, so an attacker who obtains your API key still cannot move funds for an identity that has a key.

Example use cases

  • End user approval on a phone. Your mobile app generates the key in the phone’s secure hardware and unlocks it with Face ID, a fingerprint, or a passcode. Each signature shows the end user started the operation on their own phone.
  • A separate signing service. Your platform holds the key in a key management service (KMS), a hardware security module (HSM), or a secure enclave, apart from the systems that hold your API key. Funds movement then needs both your API key and a signature from that service.
  • Keys held by end users. End users who run their own systems hold their own keys, and you register their public keys on their identities.
Identity signing keys are separate from request signing, which signs every API request with a key tied to your API key. The two use different headers and can be used together.

How enforcement works

Signing is enforced per identity: Enforcement starts as soon as the first key is registered, and stops when the last key is deleted. You can register more than one key per identity, for example one per phone or one per signing service. A signature from any active key is accepted. Signing keys are optional. Every app can register keys for any of its identities, and identities without keys keep working without signatures.
To require a signing key for every identity in your app, contact your Bastion representative to turn on mandatory enforcement. With it on, signed operations for an identity with no keys fail with IDENTITY_REQUIRES_SIGNING_KEY.

Signed operations

Crypto transfers sent with validate_only set to true are not checked. Registering, listing, and deleting signing keys does not need a signature. Registering payment instructions (bank accounts) does not need one either.

Set up a signing key

1

Generate a key pair

Generate an ECDSA key pair on the P-256 curve (also called secp256r1 or prime256v1). Other curves, including secp256k1, are rejected.Generate the key where it will be used to sign, and keep the private key there. Never send it to Bastion.
  • On a phone: use the iOS Secure Enclave or Android Keystore, and require Face ID, a fingerprint, or a passcode before the key signs.
  • On a server: use a KMS, an HSM, or a secure enclave that supports ECDSA P-256 keys. Keep it separate from the systems that store your API key.
Many key stores return signatures in a format that needs converting first. See Signature encoding.Export the public key as a JWK:
x and y are the 32-byte coordinates, base64url-encoded without padding.
2

Register the public key

Send the public key to Register Signing Key:
  • label is optional. Use it to tell keys apart, for example “Jane’s iPhone” or “Payments signer (prod)”.
  • kid is optional. If you send it, it must equal the key’s SHA-256 JWK thumbprint (RFC 7638). If you leave it out, Bastion computes it.
The response returns the key with its key_id, which is the JWK thumbprint. Store it with the signer: you send it as kid in every signature.Registering the same public key twice for an identity returns 409 Conflict.
From this point on, signed operations for this identity fail without a valid signature.
3

Sign each sensitive request

For each signed operation:
  1. Build the intent payload from the values in the request.
  2. Sign it as a detached JWS with the private key.
  3. Send the result in the bastion-identity-signature header with the request.

Signature format

The bastion-identity-signature header carries a detached compact JWS (RFC 7515, Appendix F): <protected header>..<signature>, with the payload section left empty. The protected header must contain: A signature is valid for 5 minutes after bastion.com/iat. Bastion rejects an iat more than 5 seconds in the future, so keep the signer’s clock synced and send the request right away. The signing input is the standard JWS input: base64url(header) + "." + base64url(payload). The signature is the raw 64-byte r || s value, base64url-encoded.

Example

This Node.js example uses only the built-in crypto module. It generates a key, computes the key ID, and signs a crypto transfer intent.
sign-intent.mjs

Signature encoding

ES256 signatures in a JWS are 64 bytes: the r and s values, each 32 bytes, joined together (RFC 7518, section 3.4). Many key management services, HSMs, phone keystores, and crypto libraries return ECDSA signatures in DER (ASN.1) format instead. A DER signature is usually 70 to 72 bytes. Bastion rejects it with INTENT_SIGNATURE_INVALID. Check your signer’s documentation for its output format, and convert DER signatures before you build the JWS:
der-to-raw.mjs
To sign with an external signer, build the header and payload as in the example above. Send the signing input, base64url(header) + "." + base64url(payload), to your signer with ECDSA using SHA-256. Convert the result if needed, then base64url-encode it as the signature.
Some signers also return the public key in DER (SubjectPublicKeyInfo) format. In Node.js, createPublicKey({ key, format: 'der', type: 'spki' }).export({ format: 'jwk' }) converts it to the JWK that Register Signing Key expects.

Intent payloads

You do not sign the request body. You sign a small JSON object, the intent, that describes the operation. Bastion rebuilds the same intent from the request it receives and checks your signature against it. If any signed value in the request differs from what was signed, verification fails.

Serialization rules

Bastion compares bytes, so serialize the intent exactly like this:
  • Keys sorted in byte order at every nesting level.
  • No whitespace between tokens.
  • Every field listed for the action is present. Fields marked omitted when empty are left out entirely when they have no value.
  • Amounts are strings, exactly as sent in the request. "25.00" and "25" produce different signatures.
  • Chains, currencies, payment methods, and other enums are the enum value names, as sent in the request (for example ETHEREUM_TESTNET, USDC, WIRE).
  • The characters <, >, &, U+2028 (line separator), and U+2029 (paragraph separator) inside strings are escaped as \u003c, \u003e, \u0026, \u2028, and \u2029. JSON.stringify leaves them unescaped, so add this step if your JSON library does the same. Most intents never contain these characters, but free-text compliance fields can.
The serialized intent for the example above is:

Fields by action

priority is not part of the intent, so it can change without a new signature.
Shown formatted for reading. Serialize it with the rules above.
  • destination.bank_account_id is the payment_instructions_id from the request.
  • quote_id is omitted when empty.
KYC and KYB payloads can include large document images, so the intent carries a hash of the data rather than the data itself.Build compliance_data by serializing the kyc or kyb object from your request with the serialization rules, using the fields below, then hashing the UTF-8 bytes with SHA-256.
  • String fields you do not send are included as "".
  • Enum fields you do not send are included as the enum’s unspecified value, for example "ACCOUNT_PURPOSE_UNSPECIFIED".
  • Boolean fields you do not send are included as false.
KYC fields: account_purpose, additional_id_number, country_of_birth, date_of_birth, email_address, first_name, last_name, middle_name (omitted when empty), nationality, occupation, phone_number, physical_address, source_of_funds, tax_id_number.KYB fields: account_purpose, address, business_industry_code, business_type, compliance_screening_explanation, date_of_incorporation, description, email_address, formation_document, is_dao, name, ownership_document, registration_number, source_of_funds, tax_id, transmits_customer_funds, ultimate_beneficial_owners, website.Each ultimate beneficial owner: address, birth_date, control_person_title, email, first_name, gov_id_image_back (omitted when absent), gov_id_image_front, has_control, has_ownership, is_signer, last_name, phone, relationship_established_at, tax_identification_number.Addresses: city, country, postal_code, region_code, street_line_1, street_line_2.Documents: file_data, issued_date, issuing_country, type.Fields not listed here are not part of the hash.

Manage keys

  • List keys: List Signing Keys returns the active keys for an identity.
  • Remove a key: Delete Signing Key revokes a key right away. Signatures made with it stop working.
  • Rotate a key: register the new key first, then delete the old one. The identity stays protected the whole time.
  • Replace a lost or compromised key: register the new key, then delete the old one. If the end user holds the key, for example on a lost phone, confirm who they are through your own account recovery process before you register a new key.
Deleting an identity’s last key turns enforcement off for that identity, unless your app has mandatory enforcement on. Register the replacement key before you delete the old one.
Anyone with your API key can register or delete signing keys. Protect these calls in your own systems, for example by requiring a signature from an existing key, or an extra verification step, before you make them.

Errors

Signature failures return 403 Forbidden, and Bastion does not run the operation. retryable is false because sending the same request again fails the same way. Fix the cause, then send the request again.

Requests sent without a signature

If you call a signed operation without the bastion-identity-signature header, the response depends on the identity:
The identity has at least one registered key, so the request needs a signature:
Build the intent, sign it with one of the identity’s registered keys, and send the request again with the bastion-identity-signature header.

All signature error codes

Signing key registration can also return 400 Bad Request for an unsupported curve, malformed coordinates, or a kid that is not the key’s thumbprint, and 404 Not Found for an unknown identity.

Best practices

  • Show end users what they sign. When an end user approves a signature, show them the amount, currency, and destination from the intent.
  • Keep the private key apart from your API key. If the same system holds both, an attacker who gets into it can sign requests too, and the key adds no protection.
  • Sign right before sending. Signatures expire after 5 minutes.
  • Reuse the signature for retries. Retrying with the same request_id and values reuses the same intent, so the same signature works while it is still valid.
  • Test in sandbox. Signing works the same way in sandbox as in production.