# Authenticate API requests

Executive summary
**Every API request requires a JWT token for session auth. State-changing operations additionally require a digital signature.**

- JWT tokens are obtained by signing a challenge with your private key.
- Tokens expire after 4 hours by default. Generate a new one when needed.
- Intents require an additional signature over the intent payload itself.
- Each challenge can only be used once to prevent replay attacks.


Why this matters
Understanding this two-layer model is essential for API integrations. Read-only operations only need a valid JWT, but any action that modifies state (transfers, policy changes, user management) requires a cryptographic signature that creates a non-repudiable audit record. Your integration code must handle both layers correctly.

**For architects and operators**: Implement proper key management in your integration code—never hardcode private keys. Use environment variables, secrets managers, or HSM integrations. Plan for token refresh in long-running processes.

## Prerequisites

Before authenticating API requests, you must complete:

- [Generate keys and register](/products/custody/v1.34/concepts/generate-keys-register) - Your key pair and user account must be set up


You also need:

- Your private key (generated during registration)
- Your public key (in DER format with Base64 encoding)
- The authorization server URL (contact your administrator)
- Your client ID (provided by your administrator)


## Obtaining a JWT token

All API requests require a JWT (JSON Web Token) passed as a Bearer token in the `Authorization` header. You need to obtain a JWT when you first start using the API and whenever your token expires.

| Property | Value |
|  --- | --- |
| **Default expiration** | 4 hours |
| **Token type** | Bearer |
| **Issuer** | Ripple OIDC server (default) |
| **Auth server URL** | `https://openid.{environment_URL}/token` (contact your administrator) |


### How JWT authentication works

1. You generate a random challenge string.
2. You sign the challenge with your private key.
3. You submit the public key, challenge, and signature to the auth server.
4. The server verifies your signature and issues a JWT.


For security reasons, you can only use a challenge once. For every new authentication request, you must generate a new challenge and create a new signature.

### Step 1: Create and sign a challenge

Create a random string (such as a UUID) and sign it with your private key.

P-256 / secp256k1 (macOS)

```sh
# Create a random challenge (use -n to prevent trailing newline)
echo -n "$(uuidgen)" > challenge.txt

# Sign the challenge
openssl dgst -sha256 -sign privateKey.pem challenge.txt | base64
```

P-256 / secp256k1 (Linux)

```sh
# Create a random challenge (use -n to prevent trailing newline)
echo -n "$(uuidgen)" > challenge.txt

# Sign the challenge
openssl dgst -sha256 -sign privateKey.pem challenge.txt | base64 -w0
```

Ed25519 (macOS)

```sh
# Create a random challenge (use -n to prevent trailing newline)
echo -n "$(uuidgen)" > challenge.txt

# Sign the challenge
openssl pkeyutl -sign -inkey privateKey.pem -rawin -in challenge.txt -out sig.dat

# Convert to DER format and Base64 encode
cat sig.dat | hexdump -v -e '/1 "%02x"' | sed 's/\(.\{64\}\)\(.\{64\}\)/30440220\10220\2/g' | xxd -r -p | base64 -b0
```

Ed25519 (Linux)

```sh
# Create a random challenge (use -n to prevent trailing newline)
echo -n "$(uuidgen)" > challenge.txt

# Sign the challenge
openssl pkeyutl -sign -inkey privateKey.pem -rawin -in challenge.txt -out sig.dat

# Convert to DER format and Base64 encode
cat sig.dat | hexdump -v -e '/1 "%02x"' | sed 's/\(.\{64\}\)\(.\{64\}\)/30440220\10220\2/g' | xxd -r -p | base64 -w0
```

### Step 2: Request a token

Submit your credentials to the authorization server:


```http
POST https://openid.{environment_URL}/token
Content-Type: application/x-www-form-urlencoded

grant_type=password
&client_id=YOUR_CLIENT_ID
&signature=<base64_encoded_signature>
&challenge=<challenge_string>
&public_key=<base64_encoded_public_key>
```

### Step 3: Receive and store the token

The server returns a JWT:


```json
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 14400
}
```

The `expires_in` value is in seconds (14400 = 4 hours).

### Step 4: Use the token

Include the JWT in the `Authorization` header of all API requests:


```http
GET /v1/domains
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
```

## Signing intents (state-changing operations)

All state-changing operations require a **digital signature** in addition to the JWT. These operations are called "intents" and include:

- Creating entities (users, accounts, domains)
- Updating entities
- Approving or rejecting intents
- Creating transactions


The signature both proves that you explicitly authorized the specific action, and creates an immutable audit trail.

### How to sign an intent

1. **Prepare the request object**: Build the `request` object containing author, expiry, target domain, and payload (see [Intent request structure](#intent-request-structure) below)
2. **Canonicalize**: Convert the `request` object to canonical JSON format (see [Canonicalization rules](#canonicalization-rules) below)
3. **Sign**: Hash the canonical JSON with SHA-256 and sign with your private key
4. **Encode the signature**: Serialize the signature in DER format with Base64 encoding
5. **Add to request body**: Include the signature in the `signature` field of the request body (not a header)


### Intent request structure

All intents follow this structure. The signature is computed over the canonicalized `request` object only:


```json
{
  "request": {
    "author": {
      "id": "<your-user-uuid>",
      "domainId": "<your-domain-uuid>"
    },
    "expiryAt": "2026-02-25T12:00:00.000Z",
    "targetDomainId": "<target-domain-uuid>",
    "id": "<new-intent-uuid>",
    "customProperties": {},
    "payload": {
      "type": "v0_CreatePolicy",
      "...": "payload-specific fields"
    },
    "description": "Human-readable description",
    "type": "Propose"
  },
  "signature": "<base64-signature-of-canonicalized-request-object>"
}
```

Required fields
The `customProperties` field is **mandatory** at the request level. Use an empty object `{}` if you have no custom properties. Missing this field causes a validation error.

### Canonicalization rules

Canonical JSON ensures that identical data always produces identical signatures, regardless of how the JSON was originally formatted. The rules are:

| Rule | Description | Example |
|  --- | --- | --- |
| **Remove whitespace** | No spaces, tabs, or newlines | `{"a":"b"}` not `{ "a": "b" }` |
| **Sort keys alphabetically** | All object keys sorted A-Z, recursively through nested objects | `{"a":1,"b":2}` not `{"b":2,"a":1}` |
| **Omit null values** | Do not include fields with null values in your JSON | `{"a":1}` not `{"a":1,"b":null}` |
| **Preserve array order** | Arrays of objects keep their original order—do not sort array elements | `[{"z":1},{"a":2}]` stays as-is |
| **No trailing newline** | The canonicalized output must not end with a newline character | Use `jq -j` flag |


Null values cause signature failures
Do **not** include fields with `null` values in your JSON. The server removes null values during canonicalization, so if your JSON contains `"field": null`, your canonicalized version will differ from the server's, causing `InvalidSignatureError`.

**Wrong**: `{"intentTypes": null, "customProperties": {}}`

**Correct**: `{"customProperties": {}}` (omit the null field entirely)

**Example transformation:**

Original JSON:


```json
{
  "type": "v0_CreateTransactionOrder",
  "note": null,
  "details": {
    "amount": "100",
    "asset": "USD"
  }
}
```

Canonical JSON:


```json
{"details":{"amount":"100","asset":"USD"},"type":"v0_CreateTransactionOrder"}
```

### Example: Sign an intent payload

Use `jq` to canonicalize the JSON before signing:


```sh
cat intent.json | jq -j -S -c > intent_sorted.json
```

**jq flags explained:**

- `-S` — Sort object keys alphabetically
- `-c` — Compact output (removes whitespace)
- `-j` — No trailing newline (critical—a newline changes the hash)


The `-j` flag is essential. Without it, `jq` adds a trailing newline that becomes part of the hashed content, causing signature verification to fail.

P-256 / secp256k1 (macOS)

```sh
# Canonicalize the JSON payload
cat intent.json | jq -j -S -c > intent_sorted.json

# Sign the sorted payload
openssl dgst -sha256 -sign privateKey.pem intent_sorted.json | base64 -b0
```

P-256 / secp256k1 (Linux)

```sh
# Canonicalize the JSON payload
cat intent.json | jq -j -S -c > intent_sorted.json

# Sign the sorted payload
openssl dgst -sha256 -sign privateKey.pem intent_sorted.json | base64 -w0
```

Ed25519 (Linux)

```sh
# Canonicalize the JSON payload
cat intent.json | jq -j -S -c > intent_sorted.json

# Hash and sign
tmp=$(mktemp)
cat intent_sorted.json | sha256sum | xxd -r -p > ${tmp}
openssl pkeyutl -sign -inkey privateKey.pem -rawin -in ${tmp} | hexdump -v -e '/1 "%02x"' | sed 's/\(.\{64\}\)\(.\{64\}\)/30440220\10220\2/g' | xxd -r -p | base64 -w0
rm -f ${tmp}
```

Ed25519 (macOS)

```sh
# Canonicalize the JSON payload
cat intent.json | jq -j -S -c > intent_sorted.json

# Hash and sign
tmp=$(mktemp)
cat intent_sorted.json | sha256sum | xxd -r -p > ${tmp}
openssl pkeyutl -sign -inkey privateKey.pem -rawin -in ${tmp} | hexdump -v -e '/1 "%02x"' | sed 's/\(.\{64\}\)\(.\{64\}\)/30440220\10220\2/g' | xxd -r -p | base64 -b0
rm -f ${tmp}
```

### Complete example: Create a policy

This example shows the full flow for submitting a signed intent to create a policy.

**1. Create the request object** (`request.json`):


```json
{
  "author": {
    "id": "461dac3d-55c6-4ce8-9ce2-dfb97cfbe279",
    "domainId": "67241e7d-f345-459e-9293-475693c45d85"
  },
  "expiryAt": "2026-02-25T12:00:00.000Z",
  "targetDomainId": "67241e7d-f345-459e-9293-475693c45d85",
  "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
  "customProperties": {},
  "payload": {
    "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
    "alias": "Daily Operations Policy",
    "rank": 100,
    "intentTypes": ["v0_CreateTransferOrder"],
    "scope": "Self",
    "scriptingEngine": "Javascript_v0",
    "condition": {
      "expression": "true",
      "type": "Expression"
    },
    "workflow": [{"role": "admin", "quorum": 1, "type": "RoleQuorum"}],
    "lock": "Unlocked",
    "customProperties": {},
    "type": "v0_CreatePolicy"
  },
  "description": "Create a policy for daily operations",
  "type": "Propose"
}
```

**2. Canonicalize and sign**:


```sh
# Canonicalize (sort keys, compact, no trailing newline)
cat request.json | jq -j -S -c > request_canonical.json

# Sign (P-256/secp256k1 on macOS)
SIGNATURE=$(openssl dgst -sha256 -sign privateKey.pem request_canonical.json | base64 | tr -d '\n')
```

**3. Build and submit the full request**:


```sh
# Build the full request body with signature
REQUEST=$(cat request.json)
cat > intent_body.json << EOF
{
  "request": $REQUEST,
  "signature": "$SIGNATURE"
}
EOF

# Submit to the API
curl -X POST "https://api.example.com/v1/intents" \
  -H "Authorization: Bearer $JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d @intent_body.json
```

**Expected response**:


```json
{
  "requestId": "8737c2a2-e8e1-46bf-a2a4-9918437f4a29"
}
```

The signature is computed from the **canonicalized** `request` object. The server canonicalizes your request and verifies the signature matches. Any difference in canonicalization (including null values or trailing newlines) will cause signature verification to fail.

## Approving intents

Approving an intent uses a different request structure than proposing. For complete details on the approval workflow, see [Approve and reject intents](/products/custody/v1.34/api/get-started/key-operations/update/intent-approval).

### Approval request structure

| Field | Proposal | Approval |
|  --- | --- | --- |
| `id` | Required (new intent UUID) | Not used |
| `intentId` | Not used | Required (existing intent UUID) |
| `payload` | Required (the operation details) | Not used |
| `proposalSignature` | Not used | Required (from original proposal) |
| `expiryAt` | Required | Required |
| `type` | `"Propose"` | `"Approve"` |


**Example approval request:**


```json
{
  "request": {
    "author": {
      "id": "828b554c-c9c9-11eb-a79b-dcfb48cfb3cb",
      "domainId": "455ad43e-cdd9-11eb-8465-dcfb48cfb3cb"
    },
    "targetDomainId": "455ad43e-cdd9-11eb-8465-dcfb48cfb3cb",
    "expiryAt": "2026-02-26T12:00:00.000Z",
    "intentId": "846b5c3c-27e3-4146-ab18-ce6732624d35",
    "proposalSignature": "<signature-from-original-proposal>",
    "approvalReason": "Approved after review",
    "type": "Approve"
  },
  "signature": "<base64-signature-of-canonicalized-request-object>"
}
```

The `proposalSignature` is retrieved from the original intent's `data.details.proposalSignature` field.

## JWT and intent author identity

Critical: JWT must match intent author
The JWT token identifies the API caller. When submitting an intent (proposal or approval), the `author.id` in the request **must correspond to the user identified by the JWT**.

You cannot use one user's JWT to submit intents on behalf of another user. This is enforced at the API layer.

### Why this matters

The two-layer authentication model enforces:

1. **JWT identifies the caller** — The server extracts user identity from the JWT token
2. **Signature proves authorization** — The digital signature proves the author explicitly authorized the action
3. **Both must be consistent** — The JWT user and the intent author must be the same person


This prevents delegation attacks where a compromised JWT could be used to forge actions for other users.

## Troubleshooting

| Error | Cause | Resolution |
|  --- | --- | --- |
| `InvalidJwtError` | JWT token is expired or malformed | Obtain a new JWT token |
| `InvalidSignatureError` | Signature doesn't match the payload | Verify payload is properly canonicalized (sorted, no whitespace, no nulls) |
| `InvalidSignatureError` | Wrong private key used | Ensure you're signing with the private key that matches your registered public key |
| `InvalidSignatureError` | Trailing newline in challenge | Use `echo -n` when creating the challenge to prevent trailing newlines |
| `PermissionDeniedError` | JWT user doesn't match intent author | Ensure the JWT token belongs to the user specified in `author.id` |
| `PermissionDeniedError` | User lacks permission for the operation | Check your user roles and domain permissions with your administrator |
| `PermissionDeniedError` | User not in domain's readAccess | Ensure your roles are included in the domain's `readAccess` permissions |
| `InvalidIntentError: This user has already signed` | Proposer attempting to approve own intent | A different user must approve; proposers cannot self-approve (dual control) |
| 401 Unauthorized | Missing or invalid Authorization header | Ensure the JWT is included as `Bearer <token>` |


## Related topics

- [Authentication](/products/custody/v1.34/concepts/authentication) - Conceptual overview of authentication in Ripple Custody
- [Generate keys and register](/products/custody/v1.34/concepts/generate-keys-register) - One-time setup for new users
- [Approve and reject intents](/products/custody/v1.34/api/get-started/key-operations/update/intent-approval) - Complete approval workflow
- [Verifying Custody signatures](/products/custody/v1.34/concepts/data-integrity#verifying-custody-signatures) - How to verify signatures on data returned by the platform