Skip to Content
API ReferenceAuthentication

Authentication

All Foir API requests require authentication. This page covers API key types, authentication methods, scopes, customer JWT authentication, and security best practices.

API Key Types

Foir uses two categories of API keys — public and secret — distinguished by their prefix. Keys look like pk_<hex> and sk_<hex>; there is no separate live/test environment. Both categories also have tenant-scoped variants (pk_t_ / sk_t_) for a single deploy that needs to reach several projects in one tenant — see Tenant Keys.

Public Keys

Public keys are designed for use in frontend applications. They are read-only and can read only published content.

PrefixBehavior
pk_Read-only; returns published content only. Cannot be granted drafts:read or any secret-only scope (rejected at key-creation time), so a public key can never read drafts. Safe to embed in browser bundles.

Secret Keys

Secret keys provide full read/write access to the API and must only be used server-side.

PrefixBehavior
sk_Full read/write access; can be granted additional scopes, including drafts:read. The API rejects sk_ keys on requests that carry a browser Origin / Sec-Fetch-Site.

Secret keys should never be exposed in client-side code, browser bundles, or public repositories.

Tenant Keys (multi-project access)

A project-bound pk_ / sk_ key serves exactly one project. When a single deploy needs to read several projects in the same tenant — for example one storefront serving many merchant projects — use a tenant key instead.

PrefixBehavior
pk_t_Tenant-scoped publishable key. Read-only, published content only, browser-safe — the multi-project analogue of pk_.
sk_t_Tenant-scoped secret key. Full read/write, server-side only (rejected from a browser) — the multi-project analogue of sk_.

A tenant key carries an explicit project allow-list: the exact set of projects it may reach, fixed when the key is created. It never crosses tenants (the allow-list is always within the key’s own tenant) and reaches only the projects on its list. The caller picks which of those projects each request acts on with the X-Project-Id header.

Tenant keys take the same scopes as their project-bound siblings: a pk_t_ is restricted to the read-only public subset, while a sk_t_ may hold write and secret scopes (including provision for fleet provisioning). They are created by selecting a tenant key type and choosing the projects that make up the allow-list.

Authentication Methods

Include the API key in the x-api-key header:

curl -X POST https://api.foir.dev/graphql \ -H "Content-Type: application/json" \ -H "x-api-key: pk_..." \ -d '{"query": "{ page(naturalKey: \"homepage\") { _id } }"}'

Selecting a Project (X-Project-Id)

A tenant key can reach several projects, so every request made with one must name the target project in the X-Project-Id header:

curl -X POST https://api.foir.dev/graphql \ -H "Content-Type: application/json" \ -H "x-api-key: sk_t_..." \ -H "X-Project-Id: <project-id>" \ -d '{"query": "{ pages(first: 10) { edges { node { _id } } } }"}'

The selected project must be on the key’s allow-list:

  • Missing header400X-Project-Id is required for a tenant API key.
  • Project not on the allow-list403X-Project-Id is not on the key's project allow-list.

Project-bound pk_ / sk_ keys ignore X-Project-Id entirely — each is permanently bound to its own project, and sending the header cannot redirect it elsewhere. (A project key can never be steered to a different project by a stray or forged header.)

API Key Scopes

Each API key can be configured with specific scopes to limit what it can access. Scopes are assigned when creating or editing a key in the dashboard.

ScopeDescription
records:readRead records (also gates the generic record/records JSON escape hatches)
records:writeCreate and update records
records:deleteDelete records
records:publishPublish and unpublish record versions
drafts:readRead unpublished draft content via preview: true (SECRET keys only)
files:readFetch a file by ID (file)
files:listList files (files)
files:list.allEnumerate every file across all customers (SECRET keys only; cannot be granted to a public key)
files:writeRequest uploads and confirm them (createFileUpload / confirmFileUpload, REST upload)
files:deleteSoft-delete, restore, and permanently delete files
search:readSemantic (vector) search (searchRecords and per-model search<Type>s)
search:semantic:readRead embeddings and similarity search
search:semantic:writeWrite and delete embeddings
schemas:readRead project schemas and models (for code generation)
operations:executeExecute all custom operations; also required for cancelOperationExecution
operations:execute:<operation-key>Execute only the named operation — see Per-operation scopes
operations:readRead operation execution status
notifications:readRead customer notifications and preferences
notifications:writeSend notifications and manage preferences
schedules:readRead schedules
schedules:writeTrigger schedules
secrets:read.projectRead project secrets via getSecret (SECRET keys only)
secrets:put.projectWrite project secrets via putSecret (SECRET keys only)
secrets:delete.projectDelete project secrets via deleteSecret (SECRET keys only)
provisionProvision a new project into a fleet group via the provisioning endpoint (SECRET tenant sk_t_ keys only)

Per-model record scopes (records:read:<model>, records:write:<model>, records:delete:<model>, records:publish:<model>) are also available for scoped tokens that should reach only specific models. On an API key, bound the reach with the model allow-list instead.

Public keys (pk_*) are automatically restricted to a safe subset of read-only scopes. You cannot grant write scopes — or the secret-vault and files:list.all scopes — to a public key. This check is grant-aware, not string-matching: any scope that would subsume a secret-only capability (for example secrets:*, or the bare auth:login_with_foir prefix that covers the admin login lane) is rejected on a public key too.

Per-operation scopes

operations:execute grants every operation in the project. To limit a key to specific operations, grant the three-part form instead — one entry per operation:

operations:execute:checkout operations:execute:enrich_url

The restriction is enforced at two levels:

  • Schema: execute<Op> mutations for unlisted operations are absent from the schema that key sees — introspection and codegen included. Calling one fails GraphQL validation (Cannot query field ...) rather than reaching execution.
  • Execution: the platform independently rejects execution of an unlisted operation with missing required scope: operations:execute:<operation-key>.

A key holding the unscoped operations:execute satisfies every per-operation requirement — existing keys behave exactly as before. The per-operation form covers mutation-shaped operations; query- and field-shaped operation fields are read surface and are not key-gated. cancelOperationExecution requires the unscoped form.

In the dashboard this is authored with the Operation Access selector on the key form; via the CLI, pass the three-part scopes directly to foir api-keys create --scopes.

Model-restricted keys

A key can carry a model allow-list (the Model Access selector on the key form). When set, the key’s record scopes are bounded to the listed models:

  • Typed fields for unlisted models (tag, createTag, searchTags, …) are absent from the key’s schema and denied if called.
  • The generic record fields — record, records, recordVersions, searchRecords, batchRecordOperations — are unavailable to a model-restricted key, since they can reach any model. Use the typed per-model fields.
  • Responses are cached separately per restriction, so a restricted key can never be served another key’s cached data.

Semantic-search scopes (search:semantic:read / search:semantic:write) are not model-bounded — don’t grant them to a key that must not surface other models’ content.

Customer JWT Authentication

For customer-facing features (authenticated user areas, customer-owned records, personalized content), combine an API key with a customer JWT.

After a customer logs in via the Customer Auth mutations, they receive an access token. Include both the API key and the customer token in subsequent requests:

curl -X POST https://api.foir.dev/graphql \ -H "Content-Type: application/json" \ -H "x-api-key: pk_..." \ -H "Authorization: Bearer eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9..." \ -d '{"query": "{ currentUser { id email } }"}'
const response = await fetch("https://api.foir.dev/graphql", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": process.env.NEXT_PUBLIC_FOIR_API_KEY, "Authorization": `Bearer ${customerAccessToken}`, }, body: JSON.stringify({ query, variables }), });

The API key identifies the project and the JWT identifies the customer. Requests with a valid customer token can access customer-specific queries like currentUser and records scoped to that customer.

Customer access tokens are short-lived EdDSA JWTs (issuer foir-customer). You don’t need to verify them yourself — the platform does — but if a backend-for-frontend wants to, fetch the public keys from https://api.foir.dev/.well-known/jwks.json. Never ship a token verifier to the browser.

Personalized & targeted reads

The customer JWT isn’t only for customer-owned queries like currentUserforward it on content reads too to unlock auth/segment targeting and customer-scoped records. Resolution keys off the request’s principal:

Credentials sentPrincipalcontext.authStatus
x-api-key onlyanonymous baseline"" (empty)
x-api-key + customer JWTthat customer (their role)"authenticated"

So a variant rule such as authStatus equals authenticated only matches when the token is forwarded on that query. Send only the api-key and the read resolves anonymously — the variant never fires, even for a signed-in customer. (There is no "anonymous" value; target signed-out via the base/default variant.)

Caching. An api-key-only read is identical for every anonymous visitor and is shared-cacheable; a token-bearing read resolves per-customer and is not. Because the token only exists once a customer signs in, anonymous traffic stays fully cacheable — only signed-in reads become per-customer. So forward the token wherever content is personalized, which for most apps is everywhere the customer is signed in.

To avoid wiring this per-query (and forgetting), @foir/sdk provides a session-bound content client that forwards the token automatically when present:

import { createFoirContentClient } from "@foir/sdk/client"; const foir = createFoirContentClient({ env, getToken: () => getCustomerToken(session), // your bearer, or null when signed out }); // Forwards the token when signed in → auth/segment targeting resolves as the // customer. No token → anonymous baseline (shared-cacheable). Same call either way. const menu = await foir.request(GetMenuDocument, { naturalKey: "header" });

Preview Mode Access

Pass preview: true on any read query to receive the latest (working) version of a record instead of the published one. Preview reads are gated behind the drafts:read scope, which can only be granted to SECRET (sk_) keys — public (pk_) keys are rejected at key-creation time, since they’re designed to be embeddable in browser bundles and would leak unpublished content.

query PreviewPage { page(naturalKey: "home", preview: true) { _id _hasDraft blocks { __typename } } }

To set this up:

  1. Create a SECRET (sk_) key in the dashboard with records:read and drafts:read scopes.
  2. Store the key in a server-only environment variable (Hydrogen Oxygen private env, Next.js server runtime config, etc.) — never expose it to the browser.
  3. Pass preview: true from your server-rendered route when the storefront’s preview flag is on.

If a request passes preview: true without drafts:read, the API responds with an authorization error rather than silently returning published content — so a misconfigured preview deploy fails loudly instead of quietly serving stale data.

Detecting drafts without loading them

Records resolved with a key that holds drafts:read carry a _hasDraft system field that reports whether unpublished changes exist. The field is omitted entirely for pk_ keys, so its mere presence in a response is itself information that callers without drafts:read cannot observe.

Cache safety

Responses that honour preview: true come back with Cache-Control: no-store, preventing CDNs and shared proxies from caching draft content even if a misconfigured upstream tries to.

Security Best Practices

Use Environment Variables

Never hardcode API keys in source code. Store them in environment variables:

// Node.js / Next.js const apiKey = process.env.FOIR_API_KEY; // Vite const apiKey = import.meta.env.VITE_FOIR_API_KEY;

Keep Secret Keys Server-Side

Secret keys (sk_*) must only be used in server-side code — API routes, server components, build scripts, or backend services. Never include them in client-side bundles.

// Next.js App Router -- Server Component (safe) async function getPageData(slug) { const response = await fetch("https://api.foir.dev/graphql", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": process.env.FOIR_SECRET_KEY, // Server-only env var }, body: JSON.stringify({ query: `query GetPage($slug: String!) { page(naturalKey: $slug) { _id } }`, variables: { slug }, }), }); return response.json(); }

Client-Side Key Safety

If you need to use API keys in client-side code:

  1. Use public keys only (pk_)
  2. Enable domain restrictions in the API key settings
  3. Configure rate limiting per key
  4. Monitor usage in the admin dashboard

Key Rotation

Rotate API keys periodically to reduce risk:

  1. Create a new key with the same scopes
  2. Update your application to use the new key
  3. Verify the new key works in production
  4. Disable the old key
  5. Delete the old key after a grace period

Troubleshooting

UNAUTHENTICATED Error

{ "errors": [{ "message": "Authentication required", "extensions": { "code": "UNAUTHENTICATED" } }] }

Common causes:

  • Missing x-api-key header
  • Invalid or expired API key
  • API key has been disabled or deleted in the dashboard

PERMISSION_DENIED Error

{ "errors": [{ "message": "Insufficient permissions", "extensions": { "code": "PERMISSION_DENIED" } }] }

Common causes:

  • Domain restriction mismatch (key is restricted to specific domains)
  • Public key attempting a write operation
  • Customer JWT expired or invalid for the requested resource

A request whose API key simply lacks the required scope is rejected with the message missing required scope: <scope> (e.g. missing required scope: records:write, or missing required scope: operations:execute:<operation-key> for a per-operation-restricted key). This scope denial is returned without an extensions.code, so branch on the message rather than a code for that case.

Cannot query field

Cannot query field "executeCheckout" on type "Mutation"

A field your query names doesn’t exist in the schema this key sees. Restricted keys get a pruned schema: per-operation scopes remove unlisted execute<Op> mutations, a model allow-list removes unlisted models’ typed fields and the generic record fields, and public keys never see write fields. If the field works with one key and not another, compare the two keys’ scopes and restrictions in the dashboard — and regenerate any codegen artifacts against the key your app actually ships with.

Project Selection Errors (Tenant Keys)

A request made with a tenant key (pk_t_ / sk_t_) is rejected when the project isn’t selected correctly:

  • 400X-Project-Id is required for a tenant API key: add the X-Project-Id header.
  • 403X-Project-Id is not on the key's project allow-list: the named project isn’t on this key’s allow-list. Use a project the key was created for, or extend the allow-list.

Project-bound pk_ / sk_ keys never raise these — they ignore X-Project-Id and always serve their own project.

Invalid Token Error

{ "errors": [{ "message": "Invalid or expired token", "extensions": { "code": "UNAUTHENTICATED" } }] }

Common causes:

  • Customer access token has expired (use the refresh token to obtain a new one)
  • Token was issued for a different project
  • Token has been revoked via logout
Last updated on