> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bastion.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Identity signing keys

> Register public keys on an identity so that crypto transfers, conversions, and compliance submissions need a signature from the matching private key.

## 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.

<Note>
  Identity signing keys are separate from [request signing](/guides/security/api-authentication), which signs every API request with a key tied to your API key. The two use different headers and can be used together.
</Note>

***

## How enforcement works

Signing is enforced per identity:

| Identity state | Result for signed operations |
| - | - |
| No signing keys registered | Requests succeed without a signature. A signature header, if sent, is ignored. |
| One or more signing keys registered | Every signed operation needs a valid signature from one of the identity's keys. |

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.

<Info>
  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`.
</Info>

### Signed operations

| Endpoint | Intent `action` |
| - | - |
| [Submit Cryptocurrency Transfer](/v2/api-reference/cryptocurrency-transfers/submit-cryptocurrency-transfer) (`POST /v2/crypto/transfers`) | `crypto_transfer` |
| [Submit Conversion](/v2/api-reference/conversions/submit-conversion) (`POST /v2/conversions`), both on-ramps and off-ramps | `conversion` |
| [Submit Compliance Data](/v2/api-reference/compliance/submit-compliance-data) (`POST /v2/identities/{identity_id}/compliance`, KYC and KYB) | `compliance_submission` |

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

<Steps>
  <Step title="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](#signature-encoding).

    Export the public key as a JWK:

    ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    {
      "kty": "EC",
      "crv": "P-256",
      "x": "zsgHUb5YXL9y_QBvIs8zeTXT6Ve0SzFvRheyI5MKk7Q",
      "y": "Jg3qIIYcSPl4g3Fmo3LmIWd-KFx0zkSnbc9sKeW9ojo"
    }
    ```

    `x` and `y` are the 32-byte coordinates, base64url-encoded without padding.
  </Step>

  <Step title="Register the public key">
    Send the public key to [Register Signing Key](/v2/api-reference/signing-keys/register-signing-key):

    ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    curl -X POST https://api.prod.bastion.com/v2/identities/{identity_id}/signing-keys \
      -H "Authorization: Bearer bas_your_api_key_here" \
      -H "Content-Type: application/json" \
      -d '{
        "public_key": {
          "kty": "EC",
          "crv": "P-256",
          "x": "zsgHUb5YXL9y_QBvIs8zeTXT6Ve0SzFvRheyI5MKk7Q",
          "y": "Jg3qIIYcSPl4g3Fmo3LmIWd-KFx0zkSnbc9sKeW9ojo"
        },
        "label": "Payments signer (prod)"
      }'
    ```

    * `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](https://www.rfc-editor.org/rfc/rfc7638)). 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`.

    <Check>
      From this point on, signed operations for this identity fail without a valid signature.
    </Check>
  </Step>

  <Step title="Sign each sensitive request">
    For each signed operation:

    1. Build the [intent payload](#intent-payloads) from the values in the request.
    2. Sign it as a [detached JWS](#signature-format) with the private key.
    3. Send the result in the `bastion-identity-signature` header with the request.

    ```bash theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    curl -X POST https://api.prod.bastion.com/v2/crypto/transfers \
      -H "Authorization: Bearer bas_your_api_key_here" \
      -H "Content-Type: application/json" \
      -H "bastion-identity-signature: eyJhbGciOiJFUzI1NiIs...In0..PiJy4J5sm1JIQIse7D1h8iZ..." \
      -d '{ ... }'
    ```
  </Step>
</Steps>

***

## Signature format

The `bastion-identity-signature` header carries a detached compact JWS ([RFC 7515, Appendix F](https://www.rfc-editor.org/rfc/rfc7515#appendix-F)): `<protected header>..<signature>`, with the payload section left empty.

The protected header must contain:

| Field | Value |
| - | - |
| `alg` | `ES256`. Bastion verifies with ES256 only. |
| `kid` | The `key_id` returned when you registered the key. |
| `bastion.com/iat` | The time of signing, as an integer number of Unix seconds. |

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.

```javascript sign-intent.mjs theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
import { createHash, generateKeyPairSync, sign } from 'node:crypto';

// 1. Generate a P-256 key pair. In production, generate it where it will sign.
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
const { kty, crv, x, y } = publicKey.export({ format: 'jwk' });
const publicJwk = { kty, crv, x, y }; // send this to Register Signing Key

// 2. The key ID is the RFC 7638 SHA-256 thumbprint of the public JWK.
const keyId = createHash('sha256')
  .update(JSON.stringify({ crv, kty, x, y }))
  .digest('base64url');

// 3. Serialize the intent with keys sorted at every level and no whitespace.
function canonicalJson(value) {
  if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
  if (value !== null && typeof value === 'object') {
    const keys = Object.keys(value).sort();
    return `{${keys.map((k) => `${canonicalJson(k)}:${canonicalJson(value[k])}`).join(',')}}`;
  }
  // Match Go's encoding/json, which escapes <, >, &, U+2028, and U+2029.
  return JSON.stringify(value).replace(
    /[<>&\u2028\u2029]/g,
    (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`,
  );
}

// 4. Sign the intent and return a detached compact JWS (header..signature).
function signIntent(intent, privateKey, keyId) {
  const header = { alg: 'ES256', kid: keyId, 'bastion.com/iat': Math.floor(Date.now() / 1000) };
  const encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url');
  const encodedPayload = Buffer.from(canonicalJson(intent)).toString('base64url');
  const signature = sign('sha256', Buffer.from(`${encodedHeader}.${encodedPayload}`), {
    key: privateKey,
    dsaEncoding: 'ieee-p1363',
  });
  return `${encodedHeader}..${signature.toString('base64url')}`;
}

const intent = {
  action: 'crypto_transfer',
  request_id: '0b5e6c2a-3f1d-4a8e-9c7b-2d4f6a8b0c1e',
  account_id: '5f0c8a6e-2b7d-4c1a-9e3f-7a2b4c6d8e0f',
  currency_symbol: 'USDC',
  amount: '25.00',
  destination_address: '0x1234567890abcdef1234567890abcdef12345678',
  destination_chain: 'ETHEREUM_TESTNET',
};

console.log(signIntent(intent, privateKey, keyId)); // value for bastion-identity-signature
```

### 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](https://www.rfc-editor.org/rfc/rfc7518#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:

```javascript der-to-raw.mjs theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// Convert a DER-encoded ECDSA P-256 signature to the 64-byte r || s form.
export function derToRawSignature(der) {
  const sig = Buffer.from(der);
  let offset = 2; // skip the SEQUENCE tag and length
  const readInt = () => {
    const len = sig[offset + 1];
    let int = sig.subarray(offset + 2, offset + 2 + len);
    offset += 2 + len;
    while (int.length > 32 && int[0] === 0) int = int.subarray(1); // drop sign padding
    return Buffer.concat([Buffer.alloc(32 - int.length), int]); // left-pad to 32 bytes
  };
  const r = readInt();
  const s = readInt();
  return Buffer.concat([r, s]);
}
```

To sign with an external signer, build the header and payload as in the [example above](#example). 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.

<Tip>
  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.
</Tip>

***

## 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:

```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
{"account_id":"5f0c8a6e-2b7d-4c1a-9e3f-7a2b4c6d8e0f","action":"crypto_transfer","amount":"25.00","currency_symbol":"USDC","destination_address":"0x1234567890abcdef1234567890abcdef12345678","destination_chain":"ETHEREUM_TESTNET","request_id":"0b5e6c2a-3f1d-4a8e-9c7b-2d4f6a8b0c1e"}
```

### Fields by action

<AccordionGroup>
  <Accordion title="crypto_transfer: transfer to a blockchain address">
    | Field | Source |
    | - | - |
    | `account_id` | Sending account ID |
    | `action` | `"crypto_transfer"` |
    | `amount` | Transfer amount |
    | `currency_symbol` | Currency symbol, for example `"USDC"` |
    | `destination_address` | Destination blockchain address |
    | `destination_chain` | Destination chain, for example `"ETHEREUM_TESTNET"` |
    | `request_id` | The request's `request_id` |

    `priority` is not part of the intent, so it can change without a new signature.
  </Accordion>

  <Accordion title="crypto_transfer: transfer to another Bastion account">
    | Field | Source |
    | - | - |
    | `account_id` | Sending account ID |
    | `action` | `"crypto_transfer"` |
    | `amount` | Transfer amount |
    | `currency_symbol` | Currency symbol |
    | `destination_account_id` | Receiving Bastion account ID |
    | `request_id` | The request's `request_id` |
  </Accordion>

  <Accordion title="conversion: on-ramp (fiat to stablecoin)">
    ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    {
      "account_id": "...",
      "action": "conversion",
      "amount": "100",
      "destination": { "chain": "ETHEREUM_TESTNET", "currency": "USDC", "payment_method": "BLOCKCHAIN" },
      "request_id": "...",
      "source": { "currency": "USD", "payment_method": "WIRE" }
    }
    ```

    Shown formatted for reading. Serialize it with the [rules above](#serialization-rules).
  </Accordion>

  <Accordion title="conversion: off-ramp (stablecoin to fiat)">
    ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    {
      "account_id": "...",
      "action": "conversion",
      "amount": "100",
      "destination": { "bank_account_id": "...", "currency": "MXN", "payment_method": "SPEI" },
      "quote_id": "...",
      "request_id": "...",
      "source": { "chain": "ETHEREUM_TESTNET", "currency": "USDC", "payment_method": "BLOCKCHAIN" }
    }
    ```

    * `destination.bank_account_id` is the `payment_instructions_id` from the request.
    * `quote_id` is omitted when empty.
  </Accordion>

  <Accordion title="compliance_submission: KYC and KYB">
    | Field | Source |
    | - | - |
    | `action` | `"compliance_submission"` |
    | `compliance_data` | Lowercase hex SHA-256 of the serialized KYC or KYB object (see below) |
    | `identity_id` | The identity ID in the path |

    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](#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.
  </Accordion>
</AccordionGroup>

***

## Manage keys

* **List keys:** [List Signing Keys](/v2/api-reference/signing-keys/list-signing-keys) returns the active keys for an identity.
* **Remove a key:** [Delete Signing Key](/v2/api-reference/signing-keys/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.

<Warning>
  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.
</Warning>

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:

<Tabs>
  <Tab title="Identity has a signing key">
    The identity has at least one registered key, so the request needs a signature:

    ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    {
      "code": "SIGNED_INTENT_REQUIRED",
      "message": "This identity requires a signed device intent for this operation.",
      "retryable": false
    }
    ```

    Build the intent, sign it with one of the identity's registered keys, and send the request again with the `bastion-identity-signature` header.
  </Tab>

  <Tab title="Identity has no signing key">
    With mandatory enforcement off (the default), the request succeeds without a signature.

    With mandatory enforcement on, the identity needs a key before it can run signed operations:

    ```json theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    {
      "code": "IDENTITY_REQUIRES_SIGNING_KEY",
      "message": "This identity requires a signing key for this operation.",
      "retryable": false
    }
    ```

    [Register a signing key](#set-up-a-signing-key) for the identity, then send the request again with a signature.
  </Tab>
</Tabs>

### All signature error codes

| Code | Cause | What to do |
| - | - | - |
| `SIGNED_INTENT_REQUIRED` | The identity has a key, but the `bastion-identity-signature` header is missing. | Sign the intent and send the header. |
| `SIGNING_KEY_NOT_FOUND` | `kid` does not match an active key for this identity. | Check the key ID, or register the signer's key. |
| `INTENT_SIGNATURE_INVALID` | The JWS is malformed, `kid` or `bastion.com/iat` is missing, or the signature does not match the intent Bastion built. | Check the [serialization rules](#serialization-rules) and that every signed value matches the request. |
| `SIGNED_INTENT_EXPIRED` | `bastion.com/iat` is more than 5 minutes old. The error details include `expired_at`. | Sign again just before you send the request. |
| `SIGNED_INTENT_CLOCK_SKEW` | `bastion.com/iat` is in the future. The error details include `server_time` and `max_skew_seconds`. | Sync the signer's clock, or use `server_time` to correct for drift. |
| `IDENTITY_REQUIRES_SIGNING_KEY` | Your app has mandatory enforcement on, and this identity has no keys. | Register a signing key for the identity. |

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](/guides/getting-started/sandbox-vs-production-environments) as in production.


## Related topics

- [List Signing Keys](/v2/api-reference/signing-keys/list-signing-keys.md)
- [Register Signing Key](/v2/api-reference/signing-keys/register-signing-key.md)
- [Delete Signing Key](/v2/api-reference/signing-keys/delete-signing-key.md)
- [Rotate Webhook Signing Key](/v2/api-reference/webhooks/rotate-webhook-signing-key.md)
- [Request signing with JWTs](/v2/api-reference/authentication/request-signing.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.