SemiLayerDocs

Auth & RBAC

SemiLayer uses a layered security model: API keys authenticate the tenant (which environment you're operating against), and optional user JWTs enforce per-row access rules. Together they support everything from a simple public read to complex row-level security.

💡

Easiest path: mint, list, revoke, and rotate keys from console.semilayer.com → your env → API Keys. Plaintext is shown exactly once on creation, then only the prefix + last 4 chars survive — copy it to your secret store immediately. The CLI mirrors the same surface for scripted setups.

API Key Types

Every key is prefixed so you can spot the type at a glance and grep for accidental leaks. The general format for env-scoped keys is <prefix>_<env-slug>_<random> (e.g. pk_production_a1b2…); runner keys are rk_<org-slug>_<random>.

PrefixScopeCan do
sk_<env>_…Environment (server-side)Full read + write across the env. Bypasses lens-level rules. Backend only — never ship to a browser.
pk_<env>_…Environment (browser-safe)Subject to lens-level access rules. Safe to bundle in frontends.
ik_<env>_…Environment (write-only)Ingest webhook only (POST /v1/ingest/:lens). Cannot query, search, similar, or stream. Auto-created per env.
rk_<org>_…Organization (runner)Used only by the self-hosted runner to authenticate its outbound WebSocket to runner.semilayer.com. Not a query credential — issuing a request with one will be rejected.

Secret keys (sk_) bypass all lens-level access rules. Use them server-to-server. Never put them in a browser bundle.

Public keys (pk_) enforce every access rule declared on the lens. They're the right choice for frontend code, customer-facing widgets, and anywhere the user-side JWT (X-User-Token) is also being passed for row-level security.

Ingest keys (ik_) are write-only and limited to the ingest webhook. Use them in upstream systems that fan out change events to SemiLayer.

Runner keys (rk_) are org-scoped and live only on the runner machine. Hashed (SHA-256 with a per-token-class pepper) on our side; rotation takes effect at the next runner heartbeat (~25s).

All four types are managed from Console → API Keys (env-scoped) and Console → Runners (rk_), or via the CLI:

semilayer keys create --type pk
semilayer keys list
semilayer keys revoke <key-id>

Access Rules

Access rules are declared per-Lens in sl.config.ts under grants. They gate search, similar, query, and subscribe operations.

Rule Types

'public' — No authentication required. Requests with pk_ keys and no user token are allowed.

grants: { search: 'public' }

'authenticated' — A valid user JWT (X-User-Token header or ?userToken=) is required. The JWT is validated against your JWKS endpoint.

grants: { search: 'authenticated' }

ClaimCheck — The user JWT must contain specific claims.

grants: {
  search: {
    authenticated: true,
    claims: { role: ['admin', 'editor'] }  // user's role claim must be 'admin' or 'editor'
  }
}

Function rule — Arbitrary logic. Returns true (allow), false (deny), or { filter: {...} } (allow with a metadata filter applied to results).

grants: {
  search: (claims) => {
    if (!claims.orgId) return false
    return { filter: { orgId: claims.orgId } }  // restrict results to user's org
  }
}
ℹ️

Function rules are serialized and stored in the database. They are evaluated server-side on every request. Only use serializable logic (no closures over external variables).

Per-Operation Rules

You can set different rules for different operations:

lenses: {
  products: {
    // ... fields ...
    grants: {
      search: 'public',          // anyone can search
      similar: 'public',         // anyone can find similar
      query: 'authenticated',    // must be logged in to query
      subscribe: {               // only premium users can live-tail
        authenticated: true,
        claims: { plan: ['pro', 'enterprise'] }
      }
    },
  },
}

query is disabled by default and must be explicitly enabled. All other operations default to 'public' when no grant is set.

Dual-Token Pattern

SemiLayer uses a dual-token pattern that separates the tenant identity (API key) from the end-user identity (JWT). This lets you use a single API key per environment while enforcing per-user access rules.

Backend Request:
  Authorization: Bearer pk_live_...     ← identifies the environment
  X-User-Token:  eyJhbGci...           ← identifies the end user (signed by your auth provider)

Your auth provider issues the user JWT (Auth0, Clerk, Supabase Auth, your own system — anything that implements OIDC). Configure the JWKS URL once per Environment, and SemiLayer validates every user token against it.

Configure JWKS

In sl.config.ts:

auth: {
  client: {
    jwt: {
      jwksUrl: 'https://your-issuer/.well-known/jwks.json',
      issuer: 'https://your-issuer/',
      audience: 'https://your-api/',  // optional
    }
  }
}

BeamClient — Attaching a User Token

import { createBeam } from './semilayer'

// One shared BeamClient per environment (reuse across requests)
const beam = createBeam({
  baseUrl: 'https://api.semilayer.com',
  apiKey: 'pk_live_...',
})

// Per-request: bind the user's JWT (cheap — no network, no new socket)
async function handler(req, res) {
  const userBeam = beam.products // or:
  const scoped = await beam.products  // use beam.products directly if no user token needed

  // Use withUser on the underlying client for user-scoped requests:
  const { results } = await beam.products.search({ query: 'shoes' })
  // Without user token: rules evaluated without identity (public rules only)
}
💡

When using the generated Beam client, call withUser on the BeamClient instance inside Beam, then pass it to a fresh Beam construction. Or use BeamClient directly:

import { BeamClient } from '@semilayer/client'

const sharedClient = new BeamClient({ baseUrl, apiKey: 'pk_live_...' })

// Per request:
const userClient = sharedClient.withUser(req.headers['x-user-token'])
const { results } = await userClient.search('products', { query: 'shoes' })

Row-Level Security

Access rule functions can return a filter object that is merged into every query's metadata WHERE clause. This restricts results to rows the user is allowed to see.

grants: {
  search: (claims) => {
    // claims.sub is the user's unique ID from the JWT
    return { filter: { ownerId: claims.sub } }
  }
}

Now any search request returns only records where metadata.ownerId === claims.sub. The filter is applied server-side and cannot be bypassed by the client.

Combined with the query operation:

grants: {
  query: (claims) => {
    if (!claims.sub) return false           // unauthenticated users cannot query
    return { filter: { orgId: claims.orgId } }  // org-scoped rows only
  }
}

RBAC Decision Flow

Request arrives (API key + optional user token)
    │
    ▼
sk_ key?  ──── Yes ────→  Bypass all grants, full access
    │
    No
    ▼
pk_ or ik_ key — resolve Lens + operation
    │
    ▼
No grant set for operation?  → 403 Forbidden
    │
    'public'?  → Allow (no user token needed)
    │
    'authenticated'?  → Validate user JWT → 401 if missing/invalid → Allow
    │
    ClaimCheck?  → Validate JWT → check claims → 403 if no match → Allow
    │
    Function rule?  → Evaluate with JWT claims
                     → return false → 403
                     → return true  → Allow
                     → return { filter } → Allow + apply filter to results

Frontend Safety Checklist

  • Use pk_ keys in browser-side code, never sk_
  • Declare explicit grants for every operation you expose publicly
  • Set minScore on search to prune low-relevance results client-side
  • Use ik_ keys in ingest webhook callers — they cannot query your data
  • Never log or expose sk_ or ik_ keys in client-side bundles