# Authentication

The Aurea API uses two credentials: a **tenant API key and secret**, used to sign requests with HMAC-SHA256, and a **user access token** (JWT), sent as a bearer token.

## Overview

Each tenant has an API key and an API secret. Requests made on behalf of the tenant — creating users, signing users in, reading the tenant's public branding — are **signed** with the secret. Signing a user in returns a short-lived **access token** and a **refresh token** for that user; the access token authorises user-level calls.

1. Sign a request to [register](https://docs.aureahub.com/docs/auth-register.md), [log in](https://docs.aureahub.com/docs/auth-login.md), or sign in with [Google](https://docs.aureahub.com/docs/auth-google.md) or [Apple](https://docs.aureahub.com/docs/auth-apple.md).
2. Call user-level routes with `Authorization: Bearer <accessToken>`.
3. When the access token expires, exchange the refresh token at [POST /v1/auth/refresh](https://docs.aureahub.com/docs/auth-refresh.md).
4. When the user signs out, revoke the refresh token at [POST /v1/auth/logout](https://docs.aureahub.com/docs/auth-logout.md).

Where Aurea enables [two-factor sign-in](https://docs.aureahub.com/docs/guide-two-factor.md) for your tenant, the accounts that run it — every role but the End User — give an authenticator app's code after their password.

Instead of signing with the API secret, your server can sign those requests with a key pair it holds: register the public key as a **server credential** and send a short-lived ES256 token (Server Tokens below, and [Server Credentials](https://docs.aureahub.com/docs/guide-server-credentials.md)).

> ⚠️ Anyone holding the API secret can sign requests as your tenant, so store it like a password. Secrets are managed with an admin bearer token through `GET /v1/admin/tenants/{id}/api-secret` and `POST /v1/admin/tenants/{id}/rotate-secret`. Rotating issues a new secret, and the old one stops working immediately.

## Credentials by Route

Only the routes below check the tenant signature; no other route does. On each of them a [server token](https://docs.aureahub.com/docs/guide-server-credentials.md) may take the place of the HMAC headers: in `Authorization` where the tenant signature is the only credential, in `X-Aurea-Server-Token` where `Authorization` carries the user's access token. A tenant on the institutional security profile must use one (`401` `SERVER_TOKEN_REQUIRED`).

| Route | Tenant signature | Bearer token |
| --- | --- | --- |
| `POST /v1/auth/registerPOST /v1/auth/loginPOST /v1/auth/mfa/verifyPOST /v1/auth/googlePOST /v1/auth/apple` | Required: HMAC headers or a server token | — |
| `POST /v1/auth/forgot-password` | Required: HMAC headers or a server token | — |
| `GET /v1/tenant/public` | Required: HMAC headers or a server token | — |
| `POST /v1/mpc/service/token` | Required: HMAC headers or a server token | — |
| `GET, PUT /v1/ramp/card/customers/{externalId}GET, POST /v1/ramp/card/customers/{externalId}/sessionsGET /v1/ramp/card/customers/{externalId}/sessions/{id}PUT, DELETE /v1/ramp/card/customers/{externalId}/wallets/{family}` | Required: HMAC headers or a server token | — |
| `GET /v1/aave/positionsGET /v1/aave/rate-historyPOST /v1/aave/supplyPOST /v1/aave/withdrawPOST /v1/aave/prepare-supplyPOST /v1/aave/prepare-withdrawPOST /v1/aave/broadcast` | Required: HMAC headers or a server token in `X-Aurea-Server-Token` | Required |
| `GET, POST /v1/earn/*` | Required: HMAC headers or a server token in `X-Aurea-Server-Token` | Required |
| `POST /v1/auth/refreshPOST /v1/auth/logout` | — | — (the refresh token goes in the body) |
| `GET /v1/auth/reset-password/validatePOST /v1/auth/reset-password` | — | — (the reset token is the credential) |
| `GET /healthGET /health/ready` | — | — |
| `All other routes` | Not checked | Per endpoint — see its reference page |

## Bearer Tokens

Send the access token returned by register, login, Google/Apple sign-in or refresh in the `Authorization` header:

```http
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```

Access tokens are JWTs signed by the API (HS256). Their payload contains:

| Claim | Meaning |
| --- | --- |
| sub | User ID |
| tenantId | The user's tenant |
| role | The user's role, as stored: one of the values under Roles below — `user` for an end user |
| iat | Issued-at time, Unix seconds |
| exp | Expiry time, Unix seconds |

Every problem with the bearer token produces the same `401`: a missing header, a malformed or badly signed token, an expired token, or a token without `tenantId` for a non-admin role. The body looks like this:

```json
{
  "statusCode": 401,
  "error": "UnauthorizedError",
  "message": "Authentication required"
}
```

Some endpoints return only `error` and `message`; see [Error Handling](https://docs.aureahub.com/docs/errors.md).

## Roles

Every user has one role, carried in the access token's `role` claim and answered as `role` wherever a user is returned. It decides which routes the token may call; a route a role may not call answers `403`. Every role acts on its own tenant only.

| role | What it may do |
| --- | --- |
| user | **End User.** Its own account, wallets and payments only. |
| owner | **Owner.** Everything an Admin does, and acts on the tenant's other administrators, other Owners included. Named only by Aurea; a tenant may have several. |
| tenantadmin | **Admin.** Manages the tenant, its users and credentials; initiates, signs and approves payments. |
| non_signing_admin | **Non-signing Admin.** Manages the tenant, its users, settings, credentials and webhooks, and approves; never initiates or signs a payment, never issues a credential that pays. |
| signer | **Signer.** Initiates and signs payments — its own wallets', and the tenant's agents'; manages no user and no setting. |
| approver | **Approver.** Approves or rejects the payments waiting for an approval; initiates nothing. |
| editor | **Editor.** Edits the tenant's catalogue (tokens, dApps) and the agent payments' configuration; never pays, signs or approves. |
| viewer | **Viewer.** Reads the tenant's operations — configuration without secrets, tokens, dApps, chains, agent payments; changes nothing. |
| auditor | **Auditor.** Reads what a Viewer reads, and the users, the activity log, the [audit trail](https://docs.aureahub.com/docs/audit-events.md) and the webhooks; changes nothing. |
| security_admin | **Security Admin.** Manages the tenant's credentials and webhooks; reads the users, the activity log and the audit trail; never moves or approves funds. |
| security_auditor | **Security Auditor.** Reads the tenant's security configuration (configuration without secrets, webhooks), the users, the activity log and the audit trail; changes nothing. |

- **Wallets and payments.** Every role of a tenant reads its own wallets, transactions and notifications. Creating, importing or exporting a wallet, sending, swapping, approving a token, the dApps, the MPC ceremonies, the bank ramp, the card onramp and Earn's deposits and withdrawals are for `user`, `owner`, `tenantadmin` and `signer` only — and so is cancelling, speeding up or dropping one's own [operation](https://docs.aureahub.com/docs/guide-operations.md).
- **Accounts.** The roles that read the tenant's transactions — `owner`, `tenantadmin`, `non_signing_admin`, `signer`, `approver`, `editor`, `viewer`, `auditor` — read the tenant's [accounts](https://docs.aureahub.com/docs/guide-accounts.md); `owner`, `tenantadmin` and `non_signing_admin`, who manage its users, create and change them.
- **Giving a role.** A role is given only within the giver's own: every permission of the role given must be one the giver holds, so an Admin gives any tenant role but `owner`, and a Non-signing Admin never a role that signs. Anything else answers `403` `Cannot assign a role that holds permissions your role does not`; an Owner is named only by Aurea (`403` `Only an Aurea administrator can name an owner`).
- **Acting on another account.** Changing, deleting, or sending a password reset or an email change to another user is refused for an account whose role holds a permission the caller's does not (`Cannot change the account of a user whose role holds permissions yours does not`), and for another administrator's account unless the caller is an Owner (`Only the tenant's owner can change another administrator's account`).
- **Address book.** The roles that read the tenant's transactions read its [address book](https://docs.aureahub.com/docs/guide-address-book.md); `owner`, `tenantadmin`, `non_signing_admin` and `editor` add, change, remove and prove entries; `owner`, `tenantadmin` and `non_signing_admin` approve and reject them and change its settings.
- **Groups and the tenant's quorum.** `owner`, `tenantadmin`, `non_signing_admin`, `auditor`, `security_admin` and `security_auditor` read the tenant's [groups](https://docs.aureahub.com/docs/guide-groups.md) and its [quorum requests](https://docs.aureahub.com/docs/guide-tenant-quorum.md); `owner`, `tenantadmin`, `non_signing_admin` and `security_admin` manage the groups and decide the quorum requests they may.
- **Two-factor sign-in.** Every role but `user` enrols its own [second factor](https://docs.aureahub.com/docs/guide-two-factor.md), where Aurea enables it; `owner`, `tenantadmin` and `non_signing_admin` reset another user's, under the rules of acting on another account.
- **Security and governance.** `owner`, `tenantadmin`, `non_signing_admin` and `security_admin` manage the tenant's [server credentials](https://docs.aureahub.com/docs/guide-server-credentials.md), write and publish its [policy sets](https://docs.aureahub.com/docs/guide-policy-sets.md), and [freeze](https://docs.aureahub.com/docs/guide-freezes.md) its users, wallets and accounts and quarantine its pending operations; `auditor` and `security_auditor` also read the policy sets, the freezes and the quarantines. Every role of a tenant reads its own [operations](https://docs.aureahub.com/docs/guide-operations.md); the roles that read the tenant's transactions read all of them. `approver`, `owner`, `tenantadmin` and `non_signing_admin` register their own approval keys and approve or reject the operations a policy holds ([Approvals](https://docs.aureahub.com/docs/guide-approvals.md), institutional tenants).

## Token Lifetimes

- **Access token:** 15 minutes by default. Token responses report `"expiresIn": 900`. For the exact expiry, read the token's `exp` claim.
- **Refresh token:** 7 days by default.
- **Refresh-token rotation:** off by default. When it is on, each refresh returns a new refresh token and the old one can't be reused. See [Refresh Token → Rotation](https://docs.aureahub.com/docs/auth-refresh.md).
- **Password changes:** refresh tokens issued before the user's password was last changed are rejected.
- **Logging out:** [Log Out](https://docs.aureahub.com/docs/auth-logout.md) revokes a refresh token on the server; the access tokens already issued stay valid until they expire.

> ℹ️ Lifetimes and rotation are server settings. The values above are the defaults; the values configured in a given deployment, including production, are deployment-specific. Build clients that read `exp` and handle a returned `refreshToken`, not clients that assume fixed values.

```javascript
// Read an access token's expiry (Unix seconds). This does not verify the token.
function tokenExpiry(accessToken) {
  const payload = JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64url').toString('utf8'));
  return payload.exp;
}
```

## HMAC Request Signing

A signed request carries three headers:

| Header | Value |
| --- | --- |
| x-tenant-api-key | Your tenant API key. It must belong to an active tenant; the request runs in that tenant. |
| x-timestamp | Current Unix time in **seconds**, as a string |
| x-signature | Hex-encoded HMAC-SHA256 signature, with no prefix |

The signature is computed with the tenant's **API secret**:

```text
bodyHash      = hex( SHA-256( raw request body ) )        // SHA-256 of an empty string when there is no body
signingString = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + bodyHash
x-signature   = hex( HMAC-SHA256( key = API secret, message = signingString ) )
```

- **METHOD**: the HTTP method in upper case, for example `POST`.
- **PATH**: the request path *including the query string*, exactly as in the request line, with no scheme or host. Examples: `/v1/auth/login`, `/v1/tenant/public?lang=en`.
- **TIMESTAMP**: the same string you send in `x-timestamp`. It must be within **300 seconds** of the server's clock, in either direction.
- **Body**: hash the exact bytes you send. Serialise the JSON once and send that same string; re-serialising can change whitespace or key order and break the signature.
- **Single use**: the API accepts each signature only once within the 300-second window. To retry, sign the request again. Two identical requests signed in the same second produce the same signature, so the second one is rejected.

```javascript
import { createHash, createHmac } from 'node:crypto';

export function signedHeaders(method, pathWithQuery, body = '') {
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const bodyHash = createHash('sha256').update(body).digest('hex');
  const signingString = `${method.toUpperCase()}\n${pathWithQuery}\n${timestamp}\n${bodyHash}`;
  const signature = createHmac('sha256', process.env.AUREA_TENANT_API_SECRET)
    .update(signingString)
    .digest('hex');
  return {
    'x-tenant-api-key': process.env.AUREA_TENANT_API_KEY,
    'x-timestamp': timestamp,
    'x-signature': signature,
  };
}

// 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', ...signedHeaders('POST', path, body) },
  body,
});

// GET without a body: the body hash is the SHA-256 of an empty string.
// If the URL has a query string, include it in the signed path.
const branding = await fetch('https://api.aureahub.com/v1/tenant/public', {
  headers: signedHeaders('GET', '/v1/tenant/public'),
});
```

## Server Tokens

On the routes the tenant signature opens on its own, your server can send `Authorization: Bearer <server token>` instead of the three HMAC headers — never both (`400` `SERVER_AUTH_SCHEMES_CONFLICT`). The token is a compact JWS signed ES256 with the private key of one of your tenant's **server credentials**, whose public key you registered; Aurea never holds the private key.

```text
header  { "alg": "ES256", "typ": "aurea-server+jwt", "kid": "<credential id>" }
claims  { "uri": "POST /v1/auth/login",
          "bodyHash": "<unpadded base64url SHA-256 of the exact body>",
          "nonce": "<16–128 of A–Z a–z 0–9 _ ->",
          "iat": 1791297243, "exp": 1791297273 }
```

- `uri` is the method, a space, and the path with its query exactly as sent; `exp` is at most 30 seconds after `iat`; a nonce is accepted once.
- A refused token answers `401` with `details.code` (`SERVER_TOKEN_*`, `SERVER_CREDENTIAL_*`) and the same code in the `WWW-Authenticate` header.
- A bearer token whose `typ` is not `aurea-server+jwt` is not a server token: the request needs the HMAC headers as before.
- On Earn's routes and the savings vault's, where `Authorization` carries the user's access token, the server token goes in its own header, `X-Aurea-Server-Token`, instead of the HMAC headers; the credential must be of the user's tenant.
- A credential registered since 6 October 2026 (`roleNarrows` `true`) reaches only the routes whose permission its role holds: `403` `SERVER_CREDENTIAL_ROLE_NOT_ALLOWED` elsewhere.
- A tenant on the institutional security profile must sign with a server token wherever one is accepted: HMAC signing there answers `401` `SERVER_TOKEN_REQUIRED`.

Registering a key, signing with it, rotating, the IP allow-list and every refusal: [Server Credentials](https://docs.aureahub.com/docs/guide-server-credentials.md).

## Signature Errors

A request that fails the signature check gets one of these responses. On routes that validate the request body, validation runs *before* the signature check, so an invalid body returns `400` even if the signature is also wrong.

| Status | message | Cause |
| --- | --- | --- |
| 401 | `Missing required HMAC headers: x-tenant-api-key, x-timestamp, x-signature` | One or more of the three headers is missing. |
| 401 | `Invalid x-timestamp header` | x-timestamp is not an integer. |
| 401 | `Request expired` | x-timestamp is more than 300 seconds away from the server time. |
| 404 | `Invalid API key` | x-tenant-api-key does not match an active tenant. **Where Aurea enables it**, this answer becomes the `401` `Invalid signature` below — the same status, headers and body as a wrong signature — so that an answer never tells which keys exist: treat both alike. |
| 401 | `Invalid signature` | The signature does not match. Check the method, the path and query string, the timestamp string, the exact body bytes and the secret. |
| 401 | `Replay detected` | This exact signature was already accepted within the last 300 seconds. |
| 401 | `This tenant signs with a server token: HMAC v1 is not accepted on this route` | A tenant on the institutional security profile signed with HMAC where a server token is accepted (`details.code` `SERVER_TOKEN_REQUIRED`), after the signature was checked. See [Server Credentials](https://docs.aureahub.com/docs/guide-server-credentials.md). |
| 423 | `This tenant is frozen: nothing can be done for it until it is lifted.` | Aurea froze the tenant (`details.code` `TENANT_FROZEN`), after the signature was checked. See [Freezes & Quarantines](https://docs.aureahub.com/docs/guide-freezes.md). |

## Handling 401 During Long Flows

An access token can expire in the middle of a flow. On a `401` from a bearer-protected route, refresh once and retry the original request once. If the refresh fails or the retry still returns `401`, send the user back to sign-in rather than looping. On routes that also need a tenant signature, sign the retried request again. If refresh-token rotation may be on, run only one refresh at a time; see [Refresh Token → Implementation](https://docs.aureahub.com/docs/auth-refresh.md).

```javascript
export async function fetchWithAuth(url, init, store) {
  const withBearer = () => ({
    ...init,
    headers: { ...(init.headers || {}), Authorization: `Bearer ${store.accessToken}` },
  });

  let res = await fetch(url, withBearer());
  if (res.status !== 401) return res;

  // Refresh once
  const refresh = await fetch('https://api.aureahub.com/v1/auth/refresh', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ refreshToken: store.refreshToken }),
  });
  if (!refresh.ok) throw new Error('RE_AUTH_REQUIRED');

  const { accessToken, refreshToken } = await refresh.json();
  store.accessToken = accessToken;
  if (refreshToken) store.refreshToken = refreshToken; // returned only when rotation is on

  res = await fetch(url, withBearer());
  if (res.status === 401) throw new Error('RE_AUTH_REQUIRED');
  return res;
}
```

---

Web version: https://docs.aureahub.com/#authentication
