AACsearch
Architecture

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)"] --- Scoped

Verification 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 &lt;token&gt;"]
    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 &gt; 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/>&le; rateLimitPerMinute?"}
    Filter["Combine: userFilter && tenantId:&lt;org&gt; && (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 --> Allow

Storage rules

PropertyAdminIngest / ConnectorSearchScoped
Prefixss_search_*ss_connector_* (or ss_search_*)ss_search_*ss_scoped_*
Persisted in SearchApiKey?YesYesYesNo — derived
Stored assha256 hashsha256 hash (shared space)sha256 hashSelf-contained signed payload
Required scopeadminconnector_write / ingestsearchInherits parent (must be search)
ExpiryOptional expiresAtOptional expiresAtOptional expiresAtMandatory exp (≤ 24 h, typically 15 min)
Tenant filter applied?n/a (admin)Auto-injected at writeAuto-injected at searchAuto-injected plus scoped filter
Allowed in browser?NeverNeverOnly if origin-lockedYes (intended use)
RevocationrevokedAtrevokedAtrevokedAtWait 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-lived ss_search_* key. HMAC + TTL + filter is non-negotiable.
  • #5 Tenant isolation. tenantId:<org> is AND-combined into filter_by at the search adapter, never sent as a header — it is forgery-proof.
  • Read path — how the verification chain sits in the request pipeline.
  • Write path — how ingest keys feed the outbox.

On this page