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.
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.
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:
labelis optional. Use it to tell keys apart, for example “Jane’s iPhone” or “Payments signer (prod)”.kidis 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.
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:
- Build the intent payload from the values in the request.
- Sign it as a detached JWS with the private key.
- Send the result in the
bastion-identity-signatureheader with the request.
Signature format
Thebastion-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-incrypto 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: ther 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
base64url(header) + "." + base64url(payload), to your signer with ECDSA using SHA-256. Convert the result if needed, then base64url-encode it as the signature.
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.stringifyleaves 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.
Fields by action
crypto_transfer: transfer to a blockchain address
crypto_transfer: transfer to a blockchain address
priority is not part of the intent, so it can change without a new signature.crypto_transfer: transfer to another Bastion account
crypto_transfer: transfer to another Bastion account
conversion: on-ramp (fiat to stablecoin)
conversion: on-ramp (fiat to stablecoin)
conversion: off-ramp (stablecoin to fiat)
conversion: off-ramp (stablecoin to fiat)
destination.bank_account_idis thepayment_instructions_idfrom the request.quote_idis omitted when empty.
compliance_submission: KYC and KYB
compliance_submission: KYC and KYB
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.
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.
Errors
Signature failures return403 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 thebastion-identity-signature header, the response depends on the identity:
- Identity has a signing key
- Identity has no signing key
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_idand 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.