Authentication#

API Keys#

HashWatch uses API keys for authentication on all private endpoints. An API key looks like this:

<team-id>.<secret>

For example: acme-ir.sk_live_a1b2c3d4e5f6...

Sending your API key#

Include your key in the X-API-Key header on every request:

curl https://api.hashwatch.us/api/v1/intel/stats \
  -H "X-API-Key: acme-ir.sk_live_a1b2c3d4e5f6..."

API keys are never logged or stored in plaintext — only a secure HMAC hash is kept on the server. Keep your key confidential; it cannot be recovered if lost (request a new one from your administrator).


JWT Tokens (optional)#

For high-throughput use cases, you can exchange your API key for a short-lived JWT token (15-minute TTL) and use that for subsequent requests. This avoids the HMAC verification overhead on every call.

Obtain a token#

curl -X POST https://api.hashwatch.us/api/v1/auth/token \
  -H "X-API-Key: acme-ir.sk_live_a1b2c3d4e5f6..." \
  -H "Content-Type: application/json"

Response:

{
  "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "expires_at": "2026-06-05T03:00:00Z"
}

Use the token#

curl https://api.hashwatch.us/api/v1/intel/stats \
  -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."

Refresh before expiry#

curl -X POST https://api.hashwatch.us/api/v1/auth/refresh \
  -H "Authorization: Bearer <current-token>"

The public key used to verify JWT signatures is available at:

GET https://api.hashwatch.us/.well-known/jwks.json

Roles and Tiers#

Every API key has an assigned role (what actions are allowed) and tier (billing gate for paid features). Both must be satisfied for a request to succeed.

Permissions#

PermissionEndpointMinimum Tier
intel:statsGET /api/v1/intel/statsfree
intel:historyGET /api/v1/intel/historypaid
intel:downloadsGET /api/v1/intel/downloadspaid
intel:revocationsGET /api/v1/intel/revocationspaid
keys:manage/api/v1/admin/keys/*enterprise

Roles#

Roleintel:statsintel:historyintel:downloadsintel:revocationskeys:manage
analyst
responder
auditor
responder_full
admin

Tiers#

TierWhat it unlocks
freeintel:stats
paidintel:history, intel:downloads (when role also grants them)
enterprisekeys:manage (when role is admin)

Error responses#

All errors return JSON with an error field:

{"error": "unauthorized"}
{"error": "forbidden"}
{"error": "rate limit exceeded"}
HTTP StatusMeaning
401 UnauthorizedMissing or invalid API key / JWT
403 ForbiddenValid credential but insufficient role or tier
429 Too Many RequestsRate limit exceeded — back off and retry

Rate limits#

Endpoint groupLimit
GET /public/hash-of-day100 requests / minute / IP
POST /api/v1/auth/token10 requests / minute / key
All other authenticated endpoints60 requests / minute / key

Rate limits are enforced at the Cloudflare edge before requests reach the application. Exceeding the limit returns HTTP 429. The Retry-After header (seconds) indicates when you may retry.


Admin authentication endpoints#

These endpoints are used by the Admin Panel web UI and by automation that manages admin sessions. They are separate from the team API key flow above — they authenticate platform administrators, not data consumers.

Token endpoints (current)#

MethodPathDescription
POST/api/v1/auth/tokenExchange an API key for a short-lived JWT (15 min)
POST/api/v1/auth/refreshRenew a JWT before expiry (returns a new 15-min token)
GET/.well-known/jwks.jsonRSA public key for verifying JWT signatures

See JWT Tokens above for request/response details.


Passkey (WebAuthn) endpoints#

Passkeys use the Web Authentication API (WebAuthn). Registration and authentication each require two calls: one to get a challenge from the server, and one to return the signed response from the browser/device.

Register a new passkey#

POST /api/v1/admin/auth/passkey/register/begin

Auth required: valid admin JWT or API key
Request body: {"display_name": "MacBook Touch ID"}
Response: WebAuthn PublicKeyCredentialCreationOptions challenge object (pass to navigator.credentials.create())

POST /api/v1/admin/auth/passkey/register/complete

Auth required: same session as begin
Request body: the PublicKeyCredential object returned by the browser
Response: {"passkey_id": "...", "display_name": "MacBook Touch ID", "created_at": "..."}

Sign in with a passkey#

POST /api/v1/admin/auth/passkey/login/begin

Auth required: none
Request body: {} (or {"user_handle": "..."} to hint a specific account)
Response: WebAuthn PublicKeyCredentialRequestOptions challenge (pass to navigator.credentials.get())

POST /api/v1/admin/auth/passkey/login/complete

Auth required: none
Request body: the PublicKeyCredential assertion returned by the browser
Response: {"token": "eyJ...", "expires_at": "..."} — same JWT format as POST /api/v1/auth/token

List and remove passkeys#

GET    /api/v1/admin/auth/passkeys          # list registered passkeys for the caller
DELETE /api/v1/admin/auth/passkeys/{id}     # remove a passkey by ID

Both require a valid admin JWT.


TOTP endpoints#

Enroll TOTP#

POST /api/v1/admin/auth/totp/enroll

Auth required: valid admin JWT
Request body: {}
Response:

{
  "secret": "JBSWY3DPEHPK3PXP",
  "otpauth_url": "otpauth://totp/HashWatch%3Aplatform-admin?secret=JBSWY3D...&issuer=HashWatch",
  "qr_code_svg": "<svg>...</svg>"
}

The otpauth_url can be scanned as a QR code or entered manually into any authenticator app.

Confirm TOTP enrollment#

After scanning the QR code, verify the first code to activate TOTP:

POST /api/v1/admin/auth/totp/verify

Auth required: valid admin JWT
Request body: {"code": "123456"}
Response: {"enrolled": true} — TOTP is now active for this account

Disable TOTP#

DELETE /api/v1/admin/auth/totp

Auth required: valid admin JWT
Request body: {"code": "123456"} — must provide a valid current code to confirm
Response: {"disabled": true}


Recovery code endpoints#

Generate recovery codes#

POST /api/v1/admin/auth/recovery/generate

Auth required: valid admin JWT
Request body: {}
Response:

{
  "codes": [
    "a3b7-f2d9-1e4c",
    "9x2k-0m5p-7h3q",
    "..."
  ],
  "count": 10,
  "warning": "Store these offline. Each code can only be used once."
}

Calling this endpoint invalidates any previously generated codes for the account.

Sign in with a recovery code#

POST /api/v1/admin/auth/recovery/login

Auth required: none
Request body: {"code": "a3b7-f2d9-1e4c"}
Response: {"token": "eyJ...", "expires_at": "..."} — the used code is invalidated immediately

Remaining code count is returned in the X-Recovery-Codes-Remaining response header so the admin panel can warn when codes are running low.