Auth errors
401 / 403 responses from the AACsearch API — diagnose missing headers, wrong key prefix, revoked keys, scoped-token expiry, and origin restrictions.
| Symptom | Likely cause |
|---|---|
401 missing_bearer_token | Authorization header absent or malformed |
401 unauthorized | Empty or non-bearer token format |
401 token_expired | Scoped token TTL elapsed |
403 forbidden | Wrong key prefix for the operation, or key has been revoked |
403 invalid_or_revoked_key | Connector token revoked or never matched |
403 origin_not_allowed | Browser request origin not in allowedOrigins |
403 key_does_not_match_index | Key was issued for a different indexSlug |
Decision tree
401 missing_bearer_token
→ check Authorization header is set and starts with `Bearer `
401 unauthorized
→ token format wrong; verify it begins with ss_search_, ss_connector_,
ss_scoped_, or aa_admin_ and contains no whitespace
401 token_expired (scoped tokens only)
→ scoped token's exp passed; mint a new one server-side
403 forbidden / invalid_or_revoked_key
→ check Search → API Keys; was it revoked?
→ if not revoked: prefix mismatch — see table below
403 origin_not_allowed
→ request `Origin` header not in key's allowedOrigins[]
→ during dev, leave allowedOrigins emptyPick the right key for the operation
| Operation | Required prefix |
|---|---|
POST /api/search/... | ss_search_* or ss_scoped_* |
POST /api/multi-search | ss_search_* or ss_scoped_* |
POST /api/connectors/handshake and other /api/connectors/... | ss_connector_* |
POST /api/projects/{projectId}/sync/... | ss_connector_* |
POST /api/webhooks/sync/{indexSlug} | HMAC-SHA256 signature with index webhook secret (no Bearer) |
POST /api/v1/... (management) | aa_admin_* (server-only) |
A ss_search_* key on a connector endpoint, or an ss_connector_* key on /api/search, both return 403 forbidden. The mapping is enforced by gatePublicSearchRequest() and gateConnectorRequest() in packages/api/modules/search/lib/.
Checks to run
-
Authorization header is correct.
curl -i -X POST https://app.aacsearch.com/api/search/products \ -H "Authorization: Bearer ss_search_..." \ -H "Content-Type: application/json" \ -d '{ "q": "test" }'Look for
200. If401, copy the responseerrorfield to the table above. -
Key has not been revoked. Open Search → API Keys. Revoked keys are filtered out of the default list — toggle "Show revoked" to confirm.
-
Origin is allowed (browser only). Open dev tools → Network → click the failing request → check the
Originrequest header. It must match an entry in the key'sallowedOrigins[].// To list allowed origins programmatically: const keys = await admin.listKeys(); const yours = keys.find((k) => k.id === "key_..."); console.log(yours.allowedOrigins); -
Scoped token has not expired. Decode the payload (it is base64url, not encrypted) and check
expiresAt:const [, payloadB64] = scopedToken.replace("ss_scoped_", "").split("."); const payload = JSON.parse(Buffer.from(payloadB64, "base64url").toString()); console.log(payload.expiresAt, "vs now", Math.floor(Date.now() / 1000)); -
Index slug matches. A key issued for
indexSlug: "products"cannot searchindexSlug: "categories". Re-create the key against the right index, or omit the slug at issuance to allow all org indexes.
Fix
| Diagnosis | Fix |
|---|---|
| Header missing | Add Authorization: Bearer <key> header |
| Wrong prefix | Mint a new key of the correct type — see table above |
| Key revoked | Create a replacement: Search → API Keys → Create key |
| Origin not allowed | Add the storefront origin to allowedOrigins[] (or leave empty in dev) |
| Scoped token expired | Mint a fresh scoped token server-side; client should request a new one on 401 token_expired |
| Slug mismatch | Reissue the key without an index lock, or with the right slug |
Browser-side recovery
For widget-style integrations, treat 401 token_expired as an opportunity to refetch:
import { AacSearchClient, AacSearchError } from "@repo/search-client";
async function searchWithTokenRefresh(query: string) {
try {
return await client.search({ q: query });
} catch (err) {
if (err instanceof AacSearchError && err.code === "token_expired") {
const fresh = await fetch("/api/search-token").then((r) => r.text());
client.setApiKey(fresh);
return await client.search({ q: query });
}
throw err;
}
}Server-side, expose a Server Action (or /api/search-token route) that calls orpc.search.createScopedToken.call({ ... }) with a fresh expiresInSeconds.
Diagnostics packet
| Field | Notes |
|---|---|
| Organization ID | required |
| Time (UTC) | required |
| Request ID | from response header X-Request-Id |
| Failing key prefix | first 12 chars only — never the full key |
| Origin (if browser) | from request headers |
| Endpoint hit | full path, e.g. POST /api/search/products |
| Response body | { "error": "...", "message": "..." } |
Never paste a full API key into a ticket. The key prefix and last 4 characters are enough for support to identify it in the audit log.