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

> ⚠️ 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.

| Route | Tenant signature | Bearer token |
| --- | --- | --- |
| `POST /v1/auth/registerPOST /v1/auth/loginPOST /v1/auth/googlePOST /v1/auth/apple` | Required | — |
| `POST /v1/auth/forgot-password` | Required | — |
| `GET /v1/tenant/public` | Required | — |
| `POST /v1/mpc/service/token` | Required | — |
| `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 | Required |
| `POST /v1/auth/refresh` | — | — (the refresh token goes in the body) |
| `GET /v1/auth/reset-password/validatePOST /v1/auth/reset-password` | — | — (the reset token is the credential) |
| `GET /health` | — | — |
| `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; `null` for platform admins |
| role | `user`, `tenantadmin` or `admin` |
| 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).

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

> ℹ️ 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'),
});
```

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

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