---
title: "Authentication and Multi-Tenancy for Pixeltable Apps"
date: "2026-10-17"
author: "Pierre Brunelle"
tags:
  - Authentication
  - Multi-Tenancy
  - Pixeltable Cloud
  - FastAPI
  - RAG
  - TableModel
description: "Keep your auth provider. Verify sessions on your server, scope Pixeltable queries by tenant, and authorize private media and background jobs before returning them."
url: "https://www.pixeltable.com/blog/auth-multitenancy-pixeltable-backend"
---

# Authentication and Multi-Tenancy for Pixeltable Apps

Pixeltable apps can keep the authentication provider they already use. Verify the user's session on your application server, call Pixeltable Cloud with a server-only runtime key, and scope every data operation to the verified user or organization. Private media and background jobs need the same ownership check before their results reach the browser.

 
Consider a document search app. Alice uploads a PDF; Pixeltable splits it into chunks and builds an embedding index. Bob uploads another PDF. Both users can search, but each should see only their own documents. The backend needs to preserve that rule through uploads, vector search, deletion, file delivery, and job polling.

 
## Who is calling, and whose data can they use?

 
A Pixeltable Cloud credential answers whether a caller may invoke your service. Your app's session answers which person is using it. A runtime key shared by your application server carries no knowledge of Alice or Bob.

 
| Boundary | What establishes access | What your app must do |
| --- | --- | --- |
| User to application server | A verified session or access token | Resolve identity and check the requested action. |
| Application server to Pixeltable Cloud | A runtime key scoped to the service | Keep the key server-side and call only intended routes. |
| Service route to tenant data | Your query predicate or mutation ownership check | Apply the verified tenant id to every operation. |
| Browser to private media | A short-lived signed URL | Authorize the owning row before requesting the URL. |

 
For a web app with an existing Next.js, Remix, SvelteKit, or Python backend, that server is a natural place to verify identity. The browser sends its session and the requested action. The server supplies the tenant id when it calls Pixeltable.

 
## Declare ownership alongside the document pipeline

 
This `app.py` declares a document table, a chunk view, and routes for upload, listing, search, and deletion. The `user_id` column is the tenant boundary for a personal document library. The chunk view inherits that column from its base table, so search can apply the same predicate.

 
```python
import pixeltable as pxt
import pixeltable.functions as pxtf
from pixeltable.serving import FastAPIRouter

TableModel = pxt.model_base()
embed = pxtf.openai.embeddings.using(model='text-embedding-3-small')

class Docs(TableModel, name='docs'):
 id = pxt.Column(value=pxtf.uuid.uuid7(), primary_key=True)
 user_id: pxt.String
 title: pxt.String
 document: pxt.Document

class Chunks(
 TableModel,
 name='chunks',
 base=Docs,
 iterator=pxtf.document.document_splitter(
 Docs.document, separators='page,token_limit', limit=300, metadata='page'
 ),
):
 __indexes__ = [pxt.EmbeddingIndex(text, embedding=embed)]

@pxt.query
def list_user_docs(user_id: str) -> pxt.Query:
 return (
 Docs.where(Docs.user_id == user_id)
 .select(id=Docs.id.to_string(), title=Docs.title)
 .limit(50)
 )

@pxt.query
def search_user_chunks(user_id: str, query_text: str) -> pxt.Query:
 score = Chunks.text.similarity(string=query_text)
 return (
 Chunks.where(Chunks.user_id == user_id)
 .order_by(score, asc=False)
 .select(
 doc_id=Chunks.id.to_string(),
 title=Chunks.title,
 text=Chunks.text,
 page=Chunks.page,
 score=score,
 )
 .limit(10)
 )

api = FastAPIRouter(name='api', prefix='/v1')
api.add_insert_route(
 Docs,
 path='/docs',
 inputs=[Docs.user_id, Docs.title],
 uploadfile_inputs=[Docs.document],
 outputs=[Docs.id, Docs.title],
 background=True,
)
api.add_query_route(path='/docs/list', query=list_user_docs, method='get')
api.add_query_route(path='/docs/search', query=search_user_chunks, method='post')
api.add_delete_route(
 Docs,
 path='/docs/delete',
 match_columns=[Docs.id, Docs.user_id],
)
```

 
The search filters the chunk relation by `user_id` before selecting the ten highest-scoring matches. Fetching ten matches across all tenants and discarding unauthorized rows afterward would expose data to application code and could leave the user with fewer relevant results.

 
The delete route requires both the document UUID and `user_id` to match. If Alice supplies Bob's document id, a request scoped to Alice matches no row. Your application server must inject the verified user id for deletion just as it does for search.

 
**A required parameter does not enforce a filter.** Removing `Docs.where(Docs.user_id == user_id)` would break isolation even if the function still accepted `user_id`. Pixeltable does not turn an ownership column into an automatic row-level security policy. The predicates and ownership checks are application code that you must review and test.

 
The hosted image needs the same dependencies as your local app. For a requirements-based project, include these packages in `requirements.txt`, using your project's pinned versions. Pixeltable includes `pypdfium2` for PDF parsing; the example does not need PyMuPDF.

 
```text
pixeltable[serve]
openai
tiktoken
```

 
If the project uses uv, declare those dependencies in `pyproject.toml` and update `uv.lock` instead. Install them locally too. Keep the OpenAI provider key in Cloud managed secrets for the hosted embedding calls.

 
Run `pxt init` in the directory containing `app.py` if it is not already a Pixeltable project. Keep its local database entry and add a hosted entry to `pixeltable.toml`. Replace `myorg` and `main` with your existing organization and database:

 
```toml
[[pixeltable.database]]
name = 'pxt://myorg:main'
```

 
If `pxt init` wrote the project settings to `pyproject.toml`, use `[[tool.pixeltable.database]]` there. Wait until `pxt db status pxt://myorg:main` reports `AVAILABLE`, then run the updates from the project directory in this order:

 
```bash
pxt db update pxt://myorg:main
pxt schema update app.py pxt://myorg:main
pxt service update app.py pxt://myorg:main
pxt service list pxt://myorg:main
```

 
`pxt db update` uploads the project and builds its dependency environment. The schema update creates the tables, and the service update starts HTTP. Add `-f` to the update commands when running without an interactive terminal. Copy the service URL from `pxt service list`; its host depends on your environment. See [Configure Pixeltable Cloud](/blog/configure-pixeltable-cloud) for project file selection, managed secrets, and deployment details.

 
### For team accounts, make the organization the tenant

 
A shared workspace usually needs `org_id` for isolation and `user_id` for attribution. Store both on the base table, use the verified active organization in read and mutation predicates, and check the user's membership and permissions before calling the service. An organization id supplied in JSON proves no membership.

 
Your SaaS customer's organization and the Pixeltable Cloud organization hosting the database are different scopes. Many application tenants can share one Pixeltable database. A Cloud service grant does not create a policy for those tenants.

 
## Give the application access to its service

 
Use your developer login or a user API key for deployment. Create a separate runtime key for the application server, restricted to the `api` service:

 
```bash
pxt key create document-app --grant access:pxt://myorg:main/services/api
```

 
The secret prints once. Store it as `PIXELTABLE_API_KEY` on your server. This grant permits calls to every route in `api`; the application must still enforce ownership. It does not grant storage-signing access or permission to reconfigure the service. The [CLI key reference](https://docs.pixeltable.com/platform/cli#pxt-key) lists the supported `access:` and `manage:` grant forms.

 
## Call Pixeltable from an authenticated Next.js route

 
Set `PIXELTABLE_SERVICE_URL` to the `api` service URL copied from `pxt service list pxt://myorg:main`, with `/v1` appended for this router's prefix and no trailing slash. Keep this URL and `PIXELTABLE_API_KEY` in server environment variables.

 
This route assumes [WorkOS AuthKit is configured in the Next.js app](https://workos.com/docs/authkit/nextjs). Its `withAuth()` helper reads the session. With Clerk, Better Auth, Auth.js, or another provider, use that provider's verified server session instead.

 
```typescript
// app/api/search/route.ts
import { withAuth } from '@workos-inc/authkit-nextjs';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
 const { user } = await withAuth();
 if (!user) {
 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
 }

 const body: unknown = await request.json().catch(() => null);
 const queryText =
 body !== null && typeof body === 'object' && 'query_text' in body
 ? body.query_text
 : undefined;
 if (
 typeof queryText !== 'string' ||
 queryText.trim().length === 0 ||
 queryText.length > 2000
 ) {
 return NextResponse.json({ error: 'Invalid query' }, { status: 400 });
 }

 const serviceUrl = process.env.PIXELTABLE_SERVICE_URL;
 const apiKey = process.env.PIXELTABLE_API_KEY;
 if (!serviceUrl || !apiKey) {
 return NextResponse.json({ error: 'Search unavailable' }, { status: 503 });
 }

 try {
 const upstream = await fetch(serviceUrl + '/docs/search', {
 method: 'POST',
 headers: {
 'X-api-key': apiKey,
 'Content-Type': 'application/json',
 },
 body: JSON.stringify({ user_id: user.id, query_text: queryText }),
 cache: 'no-store',
 redirect: 'error',
 signal: AbortSignal.timeout(15000),
 });
 if (!upstream.ok) {
 return NextResponse.json({ error: 'Search unavailable' }, { status: 502 });
 }

 return NextResponse.json(await upstream.json(), {
 headers: { 'Cache-Control': 'no-store' },
 });
 } catch {
 return NextResponse.json({ error: 'Search unavailable' }, { status: 502 });
 }
}
```

 
The browser supplies only the search text. Even if it sends an extra `user_id`, the server constructs a new upstream body using `user.id`. Upload routes should follow the same rule: build the multipart form on the server with the file, validated metadata, and verified tenant id.

 
Add per-user rate limits, request-size limits, and your framework's appropriate CSRF or origin protection for cookie-authenticated mutations. An authorized upload can still consume storage and model credits. Never put a shared runtime key in `NEXT_PUBLIC_*`, browser JavaScript, or a mobile binary.

 
For a client checked against the HTTP contract, follow the [OpenAPI and TypeScript client walkthrough](/blog/serve-typed-ai-endpoint-pixeltable#generate-types). Generated types check request and response shapes; they cannot prove that a tenant predicate is present or that the server chose the right identity.

 
## When the service needs the end-user token

 
Some trusted backend services already receive an end-user JWT and need the Pixeltable-hosted application to verify it too. This is a separate deployment: a custom `FastAPI` application named `app` with a `/my-notes` route. Set `PIXELTABLE_SERVICE_URL` to that service's URL from `pxt service list`, and use a `PIXELTABLE_API_KEY` granted `access:pxt://myorg:main/services/app`.

 
Send the Cloud credential in `X-api-key` and the distinct end-user token in `Authorization`:

 
```bash
# Run from a trusted server that holds the runtime key.
curl "${PIXELTABLE_SERVICE_URL%/}/my-notes" \
 -H "X-api-key: $PIXELTABLE_API_KEY" \
 -H "Authorization: Bearer $END_USER_ACCESS_TOKEN"
```

 
The Cloud gateway is designed to validate and strip `X-api-key`, then forward a distinct `Authorization` value to your service. It removes `Authorization` when that header supplied the gateway credential or repeats the key. This describes the gateway implementation; verify the header flow in your deployed environment before relying on passthrough. Have a test route report only whether the end-user header arrived, without logging either credential, and confirm missing or invalid user tokens are rejected.

 
Your custom route must validate the forwarded token with the provider's supported verifier: check the signature with trusted keys, restrict accepted algorithms, validate the issuer and expiry, and validate the intended audience when applicable. Derive identity from verified claims and apply the tenant predicate inside the route. Decoding a JWT or forwarding it through the gateway does not authenticate the user.

 
Use synchronous `def` handlers for custom FastAPI routes that call Pixeltable's synchronous catalog APIs, so FastAPI runs that work in its threadpool. For browser and mobile clients, keep a trusted application server in front of this two-header flow; distributing the shared runtime key would let clients call the service outside your application's checks.

 
## Authorize the row before returning its media URL

 
A filtered JSON response is only part of a multimodal app's access model. A PDF, image, or video also needs a delivery path that preserves the owning row's authorization.

 
For this app's uploaded documents, a hosted media output already contains a signed URL valid for one hour. The [serving implementation](https://github.com/pixeltable/pixeltable/blob/6352463aa9d900101f6350690e50fd718b449f09/pixeltable/serving/_fastapi.py#L331-L360) uploads local response media to the home bucket and signs it; object-store media is also presigned. Authorize the row before the route returns that URL. Forwarding the service JSON gives its recipient access to the bytes until the URL expires.

 
Add a document retrieval query to the same `app.py`. It matches both the verified owner and document id, then returns the media column. The application server supplies `user_id` from the session, just as it does for search:

 
```python
@pxt.query
def get_user_document(user_id: str, doc_id: str) -> pxt.Query:
 return (
 Docs.where((Docs.user_id == user_id) & (Docs.id.to_string() == doc_id))
 .select(document=Docs.document)
 )

api.add_query_route(
 path='/docs/file', query=get_user_document, method='get', one_row=True
)
```

 
After adding the route, rerun `pxt db update pxt://myorg:main` and `pxt service update app.py pxt://myorg:main` to upload and serve the changed code. The table schema is unchanged.

 
Alice's request for Bob's document matches no row and returns 404. The upload, listing, and search routes above omit media outputs; this retrieval route returns one only after the ownership check. Store document ids, then query again when a fresh URL is needed. Anyone who receives a valid URL can use it until expiry, even after the user's session ends.

 
### If your backend separately manages object keys

 
The document example needs no separate signing request or extraction of keys from signed URLs. If another part of your backend already manages home-bucket object keys, it can request shorter-lived URLs from `POST https://www.pixeltable.com/media/{org}/{db}/sign`. Keep a separate signing credential, created with:

 
```bash
pxt key create media-signer --grant access:pxt://myorg:main
```

 
This database grant is broader than signing: it also permits invoking every service in the database and obtaining read-write bucket credentials. Keep it in the trusted component that needs that access. Do not replace the application server's service-scoped key with it.

 
```json
{
 "keys": ["authorized-object-key.pdf"],
 "expires_in": 300
}
```

 
Use a short lifetime appropriate to the viewing session; the example requests five minutes. Batch a gallery's authorized keys in one request. Store durable object references, then mint URLs when the app needs to display them.

 
**The signing service does not know your app's row ownership.** It checks the Cloud credential and its storage permission. Never turn a browser-supplied list of object keys directly into a signing request. Sign only keys resolved from rows the current user may access.

 
Supported media keys receive signed URLs on `usercontent.pxt.run`. The storage edge checks the signature and expiry. It serves uploads from a separate site from `pixeltable.com`, with sandbox and `nosniff` response headers. These controls restrict how uploaded content executes; possession of the URL is still what grants access to the bytes. Anyone given a valid URL can use it until expiry, and a session logout does not revoke it.

 
Local services use their own `/media` route for local response files. That local path is not the hosted delivery contract described above. Neither flow needs a runtime key in a media tag.

 
## Keep job polling inside the same ownership boundary

 
The upload route above uses `background=True` because document splitting and embedding can outlast a comfortable browser request. It returns a job handle; polling eventually reports `pending`, `done` with a `result`, or `error`. The [serving documentation](https://docs.pixeltable.com/howto/deployment/serving#background-jobs) describes the response contract.

 
When your application starts an upload, record the returned job id and polling URL with the verified owner in server-side storage. Your `/api/jobs/[id]` handler should authenticate the caller, check that record, and poll the stored URL using the server's Cloud credential. Accept a job id from the browser, then resolve its URL server-side; an arbitrary client-supplied polling URL must never receive your API key.

 
A job result can contain the same document metadata or private media as a normal query. Knowing a job id must not let Bob inspect Alice's upload. Polling authorization deserves a test alongside document search and deletion.

 
## Test the boundary with two users

 
Before exposing the application, create Alice's and Bob's documents with distinct content and exercise the public application routes:

 

 - A request without a valid session fails before making a Pixeltable call.

 - Alice's list and search return only Alice's rows, including when Bob's text is a better semantic match.

 - Supplying Bob's `user_id` in Alice's browser request does not change the identity sent upstream.

 - Alice cannot delete Bob's document by its UUID.

 - Alice cannot obtain a signed URL for Bob's object key or poll Bob's job.

 - For shared workspaces, a user without membership cannot select that organization by changing a request field.

 

 
Keep your existing login flow and put its verified identity into the data operations that need it. Pixeltable's `TableModel`, chunk views, embedding indexes, and HTTP routes can live in one `app.py`; your application's ownership rule must follow the document through every route that returns or changes it.
## Frequently asked questions

### Which authentication providers work with Pixeltable?

Use any provider that lets your server verify a session or access token, including Clerk, WorkOS AuthKit, Better Auth, Auth.js, Auth0, or Supabase Auth. Your application derives the user or organization id from that verified identity and passes it to tenant-scoped Pixeltable routes.

### Can I put a Pixeltable API key in a React or mobile app?

Keep shared Pixeltable runtime keys on a trusted server. Browser and mobile clients call your authenticated application API, which holds the key and enforces access to rows, media, and jobs. A scoped key limits service access; it does not identify an end user or enforce tenant filters.

### Does adding user_id enable automatic row-level security?

No. A user_id column records ownership. Your queries must filter by the verified user id, and mutations must check the same ownership boundary. This is application-enforced authorization, not a database row-level security policy. Test attempts to read, search, or delete another tenant's data.

### How should I serve private images, videos, and documents?

Return media only from a query scoped to the verified tenant and document id. Hosted outputs for managed media already contain signed URLs valid for one hour, so returning that JSON grants access to the bytes until expiry. If your backend separately manages home-bucket object keys, authorize their owning rows before requesting signed URLs and keep the broader database-access signing key on the server.

### Can a Pixeltable service verify an end-user JWT?

The Cloud gateway is designed to forward a distinct end-user Authorization header alongside a valid X-api-key, removing the gateway credential before forwarding. Verify this header flow in your deployment before relying on it. Your custom FastAPI route must verify the user token and enforce ownership; forwarding alone performs neither check.

