# Server Credentials

Sign your server's calls with a key pair your server holds instead of the API secret: register the public key once, then send each request with a short-lived ES256 token.

## Overview

- **Another way to sign the same calls.** Wherever your tenant's signature is asked for, your server can send, instead of the three HMAC headers, a **server token**: a token it signs with a P-256 private key whose public key you registered as a **server credential** — in `Authorization: Bearer` on the routes the signature opens on its own, in `X-Aurea-Server-Token` where `Authorization` carries a user's access token. [HMAC request signing](https://docs.aureahub.com/docs/authentication.md) with the API key and secret keeps working — except for a tenant on the institutional security profile (Institutional Tenants, below): each request uses one or the other.
- **Aurea never holds the private key.** You make the key pair on your server and send only the public key; a key that carries its private part is refused. No answer ever contains a secret.
- **Several at once.** Up to 10 active credentials per tenant, each with its own name, role, IP allow-list and expiry. To rotate: register the new key, move your server to it, revoke the old one — both work in between.
- **One token, one request.** A token names the request's method, path and query, and the hash of its body; it lives 30 seconds at most and is accepted once.
- Nothing to switch on: every tenant can register credentials, and a tenant that registers none sees no change.
- Managed by the tenant roles `owner`, `tenantadmin`, `non_signing_admin` and `security_admin` on their own tenant ([Roles](https://docs.aureahub.com/docs/authentication.md)), with the routes under **Server Credentials** in the API Reference.

## Where a Token Is Accepted

**In `Authorization: Bearer`**, on exactly the routes the tenant's signature opens on its own:

- `POST /v1/auth/register`, `/v1/auth/login`, `/v1/auth/google`, `/v1/auth/apple` and `/v1/auth/forgot-password`;
- `GET /v1/tenant/public`;
- `POST /v1/mpc/service/token`;
- the [card onramp for your own wallets](https://docs.aureahub.com/docs/guide-card-onramp.md): `GET` and `PUT /v1/ramp/card/customers/{externalId}`, `GET` and `POST …/sessions`, `GET …/sessions/{id}`, `PUT` and `DELETE …/wallets/{family}`;
- [identity verification](https://docs.aureahub.com/docs/guide-identity.md) [Coming soon, upon request](https://docs.aureahub.com/docs/feature-availability.md): `POST /v1/identity/verifications`, `GET /v1/identity/verifications/{id}` and `GET /v1/identity/subjects/{userId}/verifications`.

The request then runs exactly as an HMAC-signed one: same tenant, same answers. A bearer token whose header's `typ` is not `aurea-server+jwt` — a user's access token, for example — is not a server token, and the request goes to HMAC signing; a request without a server token is handled exactly as before.

**In `X-Aurea-Server-Token`**, on the routes where `Authorization` carries the signed-in user's access token and your tenant's signature goes with it: [Lending](https://docs.aureahub.com/docs/guide-lending.md)'s and [Earn](https://docs.aureahub.com/docs/guide-earn.md)'s routes, and the [tokenized assets](https://docs.aureahub.com/docs/guide-tokenized-assets.md) routes.

```http
Authorization: Bearer <the user's access token>
X-Aurea-Server-Token: <server token>
```

- The same token, claims and checks as in `Authorization` (Sign a Request), with `uri` the method and path of this request — sent instead of the three HMAC headers, never with them (`400` `SERVER_AUTH_SCHEMES_CONFLICT`).
- The user's access token is checked first, as without the header. The credential must then be of the user's own tenant: `401` `SERVER_CREDENTIAL_TENANT_MISMATCH` otherwise.
- A refusal's challenge has its own scheme, so a client never takes it for its user's session refused: `WWW-Authenticate: AureaServerToken error="invalid_token", error_description="<CODE>"` (`invalid_request` for two schemes), with the code in `details.code` as everywhere.
- The header is ignored on every other route.
- **An `Idempotency-Key` on every write that moves funds** — Earn's deposits, withdrawals and broadcasts —, where your tenant asks for it [Coming soon, upon request](https://docs.aureahub.com/docs/feature-availability.md) (a tenant on the institutional security profile by its profile): a call signed with a server token and sent without a key answers `400` `IDEMPOTENCY_KEY_REQUIRED`, and nothing runs ([Idempotency](https://docs.aureahub.com/docs/idempotency.md)). Send one anyway: your server's retry is then never a second operation.

## What a Credential's Role Reaches

A credential carries one of your tenant's roles — `tenantadmin`, `non_signing_admin`, `signer`, `approver`, `editor`, `viewer`, `auditor`, `security_admin` or `security_auditor` — given as a user's role is: never above the caller's own. A token never reaches a route other than those above; among them:

- **A credential registered since 6 October 2026** (`roleNarrows` `true`) reaches only the routes whose permission its role holds. Elsewhere its token answers `403` `SERVER_CREDENTIAL_ROLE_NOT_ALLOWED` (`This server credential's role does not hold the permission this route needs`), with the permission the route needs in `details.permission` and in the challenge's `scope` — `Bearer error="insufficient_scope", error_description="SERVER_CREDENTIAL_ROLE_NOT_ALLOWED", scope="<permission>"`. Nothing is done, and the token's nonce is not used.
- **A credential registered before** (`roleNarrows` `false`) reaches all of them whatever its role, as the API key does.
- `roleNarrows` never changes — changing a credential's role keeps it. To have a role that narrows, register a new credential and revoke the old one.

| Route | Roles that reach it (roleNarrows true) |
| --- | --- |
| GET /v1/tenant/public | every role |
| POST /v1/auth/register, /v1/auth/login, /v1/auth/google, /v1/auth/apple, /v1/auth/forgot-password | `tenantadmin`, `non_signing_admin` |
| POST /v1/mpc/service/token | `tenantadmin`, `signer` |
| The card onramp for your own wallets: /v1/ramp/card/customers/… | `tenantadmin`, `signer` |
| Earn's reads: products, disclosure, quote, positions, operations | every role |
| Identity verification: POST /v1/identity/verifications | `tenantadmin`, `non_signing_admin` |
| Identity verification: GET /v1/identity/verifications/{id}, /v1/identity/subjects/{userId}/verifications | `tenantadmin`, `non_signing_admin`, `auditor`, `security_admin`, `security_auditor` |
| Earn's writes: accepting a disclosure, deposits, withdrawals, sending signatures | `tenantadmin`, `signer` |

## Test-Only Credentials

[Coming soon, upon request](https://docs.aureahub.com/docs/feature-availability.md) A credential registered with `testOnly: true` signs for the test networks only, so a test server cannot reach a mainnet even by mistake. Test-only credentials are switched on per tenant: until then, `testOnly: true` answers `403` `TEST_ONLY_CREDENTIALS_OFF` and nothing is registered — ask [help@aureahub.com](mailto:help@aureahub.com).

- **Accepted** only on a request for a testnet, on a route that takes its network from the request: `isTestnet` `true` (in the query, `"true"`; in the body, `true`), and every `chainId` the request carries a testnet's.
- **Refused** `403` `TEST_ONLY_CREDENTIAL`, before the route runs and without using the token's nonce (sign it again for a testnet):

  - `details.network` `mainnet` — `This server credential is for test networks only and this request is for a mainnet: send it for a testnet (isTestnet: true, testnet chain ids only) or sign it with a live credential`: `isTestnet` missing or `false`, or a mainnet `chainId`;
  - `details.network` `undetermined` — `This server credential is for test networks only and this route does not take its network from the request: sign it with a live credential`: sign-up and sign-in, `GET /v1/tenant/public`, `POST /v1/mpc/service/token`, a card onramp customer, and any record read or acted on by its id (an Earn operation, a purchase session, a tokenized assets quote or operation).
- Where it is accepted: [Lending](https://docs.aureahub.com/docs/guide-lending.md)'s routes; [Earn](https://docs.aureahub.com/docs/guide-earn.md)'s products, positions, quote, deposits, withdrawals and operations list; the [card onramp](https://docs.aureahub.com/docs/guide-card-onramp.md)'s wallets and purchase sessions; the [tokenized assets](https://docs.aureahub.com/docs/guide-tokenized-assets.md) routes that name no record by its id.
- `testOnly` never changes: a live credential is a new one. The credential answers `"testOnly": true`; any other credential answers without the field and reaches what it always did. The API key and secret are not affected: keep them for production only.

## Institutional Tenants

- A tenant on the institutional security profile — agreed with Aurea [Coming soon, upon request](https://docs.aureahub.com/docs/feature-availability.md) — **signs with a server token wherever one is accepted**: the routes above, in `Authorization` or in `X-Aurea-Server-Token`. Its HMAC signature there answers `401` `SERVER_TOKEN_REQUIRED` (`This tenant signs with a server token: HMAC v1 is not accepted on this route`), with `WWW-Authenticate: Bearer error="invalid_request", error_description="SERVER_TOKEN_REQUIRED"`. The sign-in routes' error bodies keep only `error` and `message`: read the header there.
- It is checked once the HMAC signature is proven — a wrong signature still answers `Invalid signature` — and nothing is consumed.
- The IP allow-list and the expiry stay optional, for institutional tenants too.
- Every other tenant keeps HMAC signing on every route, with no deadline.

## Register a Key

Make the key pair where your server runs and keep the private key in your secret store. `newCredentialKeys()` below gives the public key to register; `serverToken()` signs a request with the private key.

```javascript
import { createHash, generateKeyPairSync, randomBytes, sign } from 'node:crypto';

// Once, on your server: a P-256 key pair. Register the public key; keep the private key secret.
export function newCredentialKeys() {
  const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
  const { kty, crv, x, y } = publicKey.export({ format: 'jwk' });
  return {
    publicKey: { kty, crv, x, y }, // the body's publicKey when you register the credential
    privateKeyPem: privateKey.export({ format: 'pem', type: 'pkcs8' }),
  };
}

const part = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');

// A token for one request: valid 30 seconds, used once.
export function serverToken(credentialId, privateKeyPem, method, pathWithQuery, body = '') {
  const iat = Math.floor(Date.now() / 1000);
  const header = { alg: 'ES256', typ: 'aurea-server+jwt', kid: credentialId };
  const claims = {
    uri: `${method.toUpperCase()} ${pathWithQuery}`,
    bodyHash: createHash('sha256').update(body).digest('base64url'),
    nonce: randomBytes(24).toString('base64url'),
    iat,
    exp: iat + 30,
  };
  const signingInput = `${part(header)}.${part(claims)}`;
  const signature = sign('sha256', Buffer.from(signingInput), { key: privateKeyPem, dsaEncoding: 'ieee-p1363' });
  return `${signingInput}.${signature.toString('base64url')}`;
}
```

Register the public key with [Register a Credential](https://docs.aureahub.com/docs/server-credential-create.md). The answer's `id` is the credential, and the `kid` your tokens name:

```bash
curl -X POST https://api.aureahub.com/v1/admin/tenants/$TENANT_ID/server-credentials \
  -H "Authorization: Bearer $TENANT_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Payments server",
    "role": "signer",
    "publicKey": { "kty": "EC", "crv": "P-256", "x": "kTxt6XgA4C3r8RE3iDiksXglx-XqsKZBKcJXz8xb9Bg", "y": "2UOzdwbCN0bU_vBmtae1dx458v3t1PXqowMQIp9qahU" },
    "allowedIps": ["203.0.113.0/24"],
    "expiresAt": "2027-10-06T00:00:00Z"
  }'
```

## Sign a Request

Send `Authorization: Bearer <token>` and none of the HMAC headers. The token is a compact JWS — `header.claims.signature`, each part unpadded base64url, at most 4096 characters:

| Part | What it holds |
| --- | --- |
| header | `{ "alg": "ES256", "typ": "aurea-server+jwt", "kid": "<credential id>" }` — those three members and no other. |
| claims.uri | The method, one space, and the path with its query exactly as sent: `POST /v1/auth/login`, `GET /v1/tenant/public?lang=en`. |
| claims.bodyHash | SHA-256 of the exact body bytes, unpadded base64url; of the empty string when there is no body (`47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU`). Send the very string you hashed. |
| claims.nonce | 16 to 128 of `A–Z a–z 0–9 _ -`, new for every token. |
| claims.iat, claims.exp | Whole Unix seconds; `exp` after `iat` by 30 seconds at most. Accepted from 10 seconds before `iat` to 10 seconds after `exp`. |
| signature | ES256 over `header.claims`, in its 64-byte form (r and s, 32 bytes each), as JOSE writes it — not DER. |

```javascript
// POST with a JSON body: sign and send the same string
const path = '/v1/auth/login';
const body = JSON.stringify({ username: 'alice', password: 'SecurePass123' });
const login = await fetch('https://api.aureahub.com' + path, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${serverToken(CREDENTIAL_ID, process.env.AUREA_SERVER_KEY_PEM, 'POST', path, body)}`,
  },
  body,
});

// GET without a body; a query string is part of the signed path
const branding = await fetch('https://api.aureahub.com/v1/tenant/public', {
  headers: { Authorization: `Bearer ${serverToken(CREDENTIAL_ID, process.env.AUREA_SERVER_KEY_PEM, 'GET', '/v1/tenant/public')}` },
});
```

- **Sign every request, retries included.** A nonce is accepted once per credential: a token sent again answers `401` `SERVER_TOKEN_REPLAYED`.
- On routes that check the request body, that check runs before the token's, as it does before an HMAC signature's.

The sample was checked against the API's own verification: tokens for a `POST` with a body and a `GET` with a query are accepted, the registered key is accepted as sent, and another body, another path or query, or another key's signature is refused.

## Rotate, Restrict, Revoke

- **Rotate with overlap.** Register the new key, move your server to it, then revoke the old credential. A key is registered once, ever — after its revocation and in any tenant: registering it again answers `409` `SERVER_CREDENTIAL_KEY_IN_USE`. A credential's key never changes: a new key is a new credential.
- **IP allow-list.** `allowedIps`: IPv4 or IPv6 networks (`203.0.113.0/24`, `2001:db8::/32`) or bare addresses, at most 50, matched against the address your server's call comes from as Aurea sees it — an `X-Forwarded-For` header you write is not trusted. Empty: any address. Write an IPv4 network as IPv4 (not IPv4-mapped IPv6), with no host bits under the mask. Changing it applies to the next token.
- **Expiry.** `expiresAt` in the future, at most 5 years ahead; past it the credential's tokens answer `401` `SERVER_CREDENTIAL_EXPIRED` and it can no longer be changed. `null`: no expiry.
- **Revoke** with a reason: for good, and at once. Its next token answers `401` `SERVER_CREDENTIAL_REVOKED`; it stays listed with `status` `revoked`, and never changes again. There is no delete. Your webhook endpoints that name `server_credential.revoked` are told, in both environments ([Webhooks & Events](https://docs.aureahub.com/docs/guide-events.md)).
- `lastUsedAt` is the last accepted token, written at most once a minute: it tells you whether a credential is still in use before you revoke it.

## Refusals

A token is checked in this order; the first check that fails answers, and nothing is done. The code is in `details.code` and in the `WWW-Authenticate` header — `Bearer error="invalid_token", error_description="<CODE>"`, `invalid_request` for two schemes, `insufficient_scope` for a role that does not reach the route; in `X-Aurea-Server-Token`, the scheme is `AureaServerToken`. The sign-in routes keep only `error` and `message` in their error bodies: there, read the header.

```json
{
  "statusCode": 401,
  "error": "UnauthorizedError",
  "message": "The server token was signed for another body",
  "details": { "code": "SERVER_TOKEN_BODY_MISMATCH" }
}
```

| Status | details.code | message |
| --- | --- | --- |
| 400 | SERVER_AUTH_SCHEMES_CONFLICT | `Send either a server token or the HMAC headers (x-tenant-api-key, x-timestamp, x-signature), not both` |
| 401 | SERVER_TOKEN_MALFORMED | `Malformed server token` |
| 401 | SERVER_TOKEN_LIFETIME_INVALID | `A server token's exp must be after its iat, by 30 seconds at most` |
| 401 | SERVER_TOKEN_NOT_YET_VALID | `Server token issued in the future` |
| 401 | SERVER_TOKEN_EXPIRED | `Server token expired` |
| 401 | SERVER_CREDENTIAL_UNKNOWN | `Unknown server credential` |
| 401 | SERVER_TOKEN_SIGNATURE_INVALID | `Invalid server token signature` |
| 401 | SERVER_CREDENTIAL_REVOKED | `Server credential revoked` |
| 401 | SERVER_CREDENTIAL_EXPIRED | `Server credential expired` |
| 401 | SERVER_CREDENTIAL_TENANT_INACTIVE | `The server credential's tenant is not active` |
| 401 | SERVER_CREDENTIAL_TENANT_MISMATCH | `The server credential belongs to another tenant than the signed-in user` |
| 401 | SERVER_CREDENTIAL_IP_NOT_ALLOWED | `This address is not in the server credential's allow-list` |
| 401 | SERVER_TOKEN_URI_MISMATCH | `The server token was signed for another method or URL` |
| 401 | SERVER_TOKEN_BODY_MISMATCH | `The server token was signed for another body` |
| 403 | SERVER_CREDENTIAL_ROLE_NOT_ALLOWED | `This server credential's role does not hold the permission this route needs` |
| 401 | SERVER_TOKEN_REPLAYED | `Server token already used` |
| 423 | TENANT_FROZEN | `This tenant is frozen: nothing can be done for it until it is lifted.` |

- `SERVER_TOKEN_MALFORMED`: not three canonical base64url parts, a header member other than `alg`, `typ` and `kid`, an `alg` other than `ES256`, a `kid` that is not a UUID, a claim missing or of the wrong shape, a signature that is not 64 bytes, or more than 4096 characters.
- Until the signature is proven, a token learns nothing about the credential: anyone who knows an id but not the key gets `SERVER_CREDENTIAL_UNKNOWN` or `SERVER_TOKEN_SIGNATURE_INVALID`.
- `SERVER_CREDENTIAL_TENANT_MISMATCH`: in `X-Aurea-Server-Token` only. `SERVER_CREDENTIAL_ROLE_NOT_ALLOWED`: a credential whose role narrows (What a Credential's Role Reaches), with `details.permission`.
- The nonce is the last check: a token refused for any other reason can be corrected and sent again with the same nonce.
- `TENANT_FROZEN`: Aurea froze your tenant ([Freezes & Quarantines](https://docs.aureahub.com/docs/guide-freezes.md)); its API key is refused the same way.

---

Web version: https://docs.aureahub.com/#guide-server-credentials
