# Authentication

Every `/v1` request is signed. You never send your secret: you send a
short-lived token signed with it.

## API keys

An API key is a pair, created in the console under **API keys** or with
[`POST /v1/api_keys`](https://sigwise.ai/docs/reference.md#tag/api-keys):

| Part | Looks like | Where it goes |
|------|------------|---------------|
| Key ID | `7Hx2Qp9LmZ` | The `api_key` query parameter of every request. Public: it identifies the key and grants nothing on its own. |
| Signing secret | `your_secret` | Only on your server. It signs a token for each request and is shown once, at creation or rotation. |

## Signing a request

Each request carries `Authorization: Bearer `, where the JWT is signed
with **HS256** using the whole secret string as the key.

```
GET /v1/objects/user-42?api_key=7Hx2Qp9LmZ
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImFrXzdIeDJRcDlMbVoifQ.eyJpYXQiOjE3OTAwMDAwMDAsImV4cCI6MTc5MDAwMDA2MCwiaHRtIjoiR0VUIiwiaHR1IjoiL3YxL29iamVjdHMvdXNlci00MiJ9.…
```

The header and claims:

```json
{ "alg": "HS256", "typ": "JWT", "kid": "7Hx2Qp9LmZ" }
```

```json
{
  "iat": 1790000000,
  "exp": 1790000060,
  "jti": "5f0c8e8e2b7d4c1e9a3d6b2f1c0e4a7b",
  "htm": "GET",
  "htu": "/v1/objects/user-42"
}
```

| Claim | Required | Meaning |
|-------|----------|---------|
| `iat` | yes | Issued at, in Unix seconds. |
| `exp` | yes | Expiry. At most **300 seconds** after `iat`. |
| `jti` | no  | A unique ID for the token. |
| `htm` | no  | Binds the token to one HTTP method. |
| `htu` | no  | Binds the token to one path (or a full URL, of which only the path is compared). |

- `kid` in the JWT header can replace the `api_key` query parameter. If you
  send both, they must match.
- The server allows **±60 seconds** of clock skew on `iat` and `exp`.
- Binding with `htm` and `htu` is optional but recommended: a leaked token then
  only works for the one request it was made for. The SDKs always bind, and
  sign a new 60-second token per request.
- Tokens in the query string (`access_token`, `token`, `jwt`) are rejected,
  because URLs end up in logs.

Every failure (unknown, inactive or revoked key, bad signature, expired or
future-dated token, wrong method or path) is the same generic `401`:

```json
{ "error": { "code": "unauthorized", "message": "unauthorized" } }
```

## Examples

Shell:

```bash
b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
sign() { # sign METHOD PATH
  local now=$(date +%s) h p
  h=$(printf '{"alg":"HS256","typ":"JWT","kid":"%s"}' "$ANALYZE_API_KEY" | b64url)
  p=$(printf '{"iat":%d,"exp":%d,"htm":"%s","htu":"%s"}' "$now" "$((now + 60))" "$1" "$2" | b64url)
  printf '%s.%s.%s' "$h" "$p" \
    "$(printf '%s.%s' "$h" "$p" | openssl dgst -sha256 -hmac "$ANALYZE_SECRET" -binary | b64url)"
}

curl "https://api.sigwise.ai/v1/me?api_key=$ANALYZE_API_KEY" \
  -H "Authorization: Bearer $(sign GET /v1/me)"
```

Node.js:

```ts
import { createHmac, randomUUID } from "node:crypto";

function sign(apiKey: string, secret: string, method: string, path: string): string {
  const b64 = (v: object) => Buffer.from(JSON.stringify(v)).toString("base64url");
  const now = Math.floor(Date.now() / 1000);
  const head = b64({ alg: "HS256", typ: "JWT", kid: apiKey });
  const body = b64({ iat: now, exp: now + 60, jti: randomUUID(), htm: method, htu: path });
  const sig = createHmac("sha256", secret).update(`${head}.${body}`).digest("base64url");
  return `${head}.${body}.${sig}`;
}
```

Python:

```python
import base64, hashlib, hmac, json, time, uuid

def sign(api_key: str, secret: str, method: str, path: str) -> str:
    b64 = lambda v: base64.urlsafe_b64encode(json.dumps(v).encode()).rstrip(b"=").decode()
    now = int(time.time())
    head = b64({"alg": "HS256", "typ": "JWT", "kid": api_key})
    body = b64({"iat": now, "exp": now + 60, "jti": uuid.uuid4().hex, "htm": method, "htu": path})
    sig = hmac.new(secret.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()
    return f"{head}.{body}." + base64.urlsafe_b64encode(sig).rstrip(b"=").decode()
```

Go:

```go
func sign(apiKey, secret, method, path string) string {
	enc := base64.RawURLEncoding
	now := time.Now().Unix()
	head, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT", "kid": apiKey})
	body, _ := json.Marshal(map[string]any{"iat": now, "exp": now + 60, "htm": method, "htu": path})
	msg := enc.EncodeToString(head) + "." + enc.EncodeToString(body)
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(msg))
	return msg + "." + enc.EncodeToString(mac.Sum(nil))
}
```

PHP:

```php
function sign(string $apiKey, string $secret, string $method, string $path): string
{
    $b64 = fn (string $v) => rtrim(strtr(base64_encode($v), '+/', '-_'), '=');
    $now = time();
    $head = $b64(json_encode(['alg' => 'HS256', 'typ' => 'JWT', 'kid' => $apiKey]));
    $body = $b64(json_encode(['iat' => $now, 'exp' => $now + 60, 'htm' => $method, 'htu' => $path], JSON_UNESCAPED_SLASHES));
    return "$head.$body." . $b64(hash_hmac('sha256', "$head.$body", $secret, true));
}
```

> **Sign the decoded path**
>
> `htu` is compared with the request's **decoded** path. For an object ID with
> characters that need escaping, like `user 42`, request
> `/v1/objects/user%2042` but sign `/v1/objects/user 42`.

## Rotating and revoking keys

- **Rotate** (`POST /v1/api_keys/{key_id}/rotate`) keeps the key ID and issues
  a new secret. The old secret stops working immediately.
- **Deactivate** (`PATCH` with `{"status": "inactive"}`) pauses a key; set it
  back to `active` to resume.
- **Revoke** (`DELETE`) retires a key for good. Its ID is never reissued.

Every console user of the account gets an email when a key is created, rotated
or revoked. See [API keys](https://sigwise.ai/docs/guides/api-keys.md).

## Console tokens

The SigWise console authenticates people, not integrations: it signs in with
an email and password (or Google or GitHub) and gets a **console token** from
the `/auth/console/*` endpoints. Those endpoints are documented under
*Console* in the [API reference](https://sigwise.ai/docs/reference.md), but integrations should always
use API keys.

### Two-factor authentication and lockout

Console users can turn on two-factor authentication with an authenticator app
from the account menu. Once it is on, signing in with a password also needs a
6-digit code, or one of the eight single-use recovery codes shown when you turned
it on. Signing in with Google or GitHub uses that provider's own security.

Repeated failed sign-ins lock the account, and the address they came from, out
for 15 minutes. The API answers `429` with a `Retry-After` header.

## Deprecated: legacy keys

Keys created before request signing have no signing secret. Until rotated,
they still authenticate by sending the old long key as `Authorization: Bearer
your_key_id` or `X-API-Key`, or by exchanging it at `POST /auth/token`. Both paths log
a warning and will be removed. Rotate a legacy key to get a signing secret.
