Key types & security model
The four AACsearch key categories — search, connector, scoped, and admin — their ss_* prefixes, hash-only storage, scoped-token HMAC + TTL + filter, and how each one is verified at request time.
AACsearch issues one physical row per credential in SearchApiKey, distinguished by
the scopes[] column. The raw key string is never stored — only a sha256 hash. Scoped
tokens are a fourth, derived credential type that lives entirely in the bearer string
(no row) and is verified by HMAC.
The four key categories
Description
The diagram groups AACsearch credentials into two buckets: three persisted row types in SearchApiKey (admin, ingest/connector, search) — each stored only as a sha256 hash with its own prefix and scope set — and a derived scoped-token type that is never persisted but signed with HMAC-SHA256 over BETTER_AUTH_SECRET. Arrows annotate the intended deployment surface (server-only, CMS, trusted backend, browser widget) and show that scoped tokens inherit from a parent search key.
graph TD
classDef admin fill:#fde68a,stroke:#b45309,color:#78350f
classDef ingest fill:#bae6fd,stroke:#0369a1,color:#075985
classDef search fill:#bbf7d0,stroke:#15803d,color:#14532d
classDef scoped fill:#ddd6fe,stroke:#6d28d9,color:#4c1d95
subgraph Persisted["SearchApiKey rows — sha256(raw) only"]
Admin["Admin key<br/>prefix: ss_search_<br/>scopes: [admin]<br/>full index control"]:::admin
Ingest["Ingest / Connector key<br/>prefix: ss_connector_ (or ss_search_)<br/>scopes: [connector_write] / [ingest]<br/>writes to SearchSyncOutbox"]:::ingest
Search["Search key<br/>prefix: ss_search_<br/>scopes: [search]<br/>read-only, origin-restricted"]:::search
end
subgraph Derived["Not persisted — verified by HMAC"]
Scoped["Scoped token<br/>prefix: ss_scoped_<br/>payload: { keyId, parentRawKey, filterBy, exp }<br/>HMAC-SHA256 over BETTER_AUTH_SECRET"]:::scoped
end
Search -. parent of .-> Scoped
UseAdmin["Server-side only<br/>NEVER in browser/CMS"] --- Admin
UseIngest["CMS connector / sync job"] --- Ingest
UseSearch["Server-rendered search<br/>(trusted backend)"] --- Search
UseScoped["Browser widget / per-user filter<br/>(short-lived, embeddable)"] --- ScopedVerification flow per request
Description
The diagram is the per-request verification flow: the prefix dispatches to either HMAC-verify-then-hash (for ss_scoped_*) or direct hash-and-lookup (for ss_search_* / ss_connector_*), followed by scope check, origin allow-list, rate-limit bucket, and finally tenant-filter AND-combine before the handler runs. Any failure short-circuits to a 401, 403, or 429.
flowchart TD
Req["Request: Authorization: Bearer <token>"]
Shape{"Prefix?"}
Scoped["ss_scoped_*"]
Search["ss_search_*"]
Connector["ss_connector_*"]
Reject["401 missing_bearer_token"]
HMAC["1. base64url decode payload<br/>2. HMAC-SHA256(payload, BETTER_AUTH_SECRET)<br/>3. timingSafeEqual(sig, expected)<br/>4. exp > now()"]
Hash["sha256(normalizeKeyForHash(raw))"]
Lookup["SELECT * FROM search_api_key<br/>WHERE hash = ?<br/>AND revokedAt IS NULL<br/>AND deletedAt IS NULL"]
ScopeCheck{"Required scope<br/>in scopes[]?"}
OriginCheck{"Origin in<br/>allowedOrigins?"}
RateLimit{"RateLimitBucket<br/>≤ rateLimitPerMinute?"}
Filter["Combine: userFilter && tenantId:<org> && (scopedFilter)"]
Allow["Proceed to handler"]
Req --> Shape
Shape --> Scoped --> HMAC --> Hash
Shape --> Search --> Hash
Shape --> Connector --> Hash
Shape -. unknown .-> Reject
Hash --> Lookup --> ScopeCheck
ScopeCheck -- no --> Reject
ScopeCheck -- yes --> OriginCheck
OriginCheck -- no --> Reject
OriginCheck -- yes --> RateLimit
RateLimit -- 429 --> Reject
RateLimit -- ok --> Filter --> AllowStorage rules
| Property | Admin | Ingest / Connector | Search | Scoped |
|---|---|---|---|---|
| Prefix | ss_search_* | ss_connector_* (or ss_search_*) | ss_search_* | ss_scoped_* |
Persisted in SearchApiKey? | Yes | Yes | Yes | No — derived |
| Stored as | sha256 hash | sha256 hash (shared space) | sha256 hash | Self-contained signed payload |
| Required scope | admin | connector_write / ingest | search | Inherits parent (must be search) |
| Expiry | Optional expiresAt | Optional expiresAt | Optional expiresAt | Mandatory exp (≤ 24 h, typically 15 min) |
| Tenant filter applied? | n/a (admin) | Auto-injected at write | Auto-injected at search | Auto-injected plus scoped filter |
| Allowed in browser? | Never | Never | Only if origin-locked | Yes (intended use) |
| Revocation | revokedAt | revokedAt | revokedAt | Wait for exp (or rotate parent) |
Hard invariants this enforces
- #3 Hash-only storage. No code path persists the raw key.
hashSearchApiKey()is the only function that touches the secret, and it returns a hex digest. - #4 Scoped tokens. Every customer-facing search box uses an
ss_scoped_*token, not a long-livedss_search_*key. HMAC + TTL + filter is non-negotiable. - #5 Tenant isolation.
tenantId:<org>is AND-combined intofilter_byat the search adapter, never sent as a header — it is forgery-proof.
Related
- Read path — how the verification chain sits in the request pipeline.
- Write path — how ingest keys feed the outbox.
Read path
How a search query flows through AACsearch — from the customer SDK to /search/public/multi, through public-auth and tenant-filter combine, into Typesense multi_search, and back as a sanitized response.
Connector lifecycle
The six connector operations — handshake, heartbeat, full-sync, delta-sync, delete, diagnostics — and how they map to the Connector API surface.