Authentication
How humans, Agents, and principal-bound authenticators access Loam.
Human and machine authentication
Loam uses separate credentials for people and integrations:
- People use Supabase access JWTs directly. The web app and other human clients send the current Supabase access JWT to the owning regional data plane. There is no Loam human-token exchange.
- Integrations use regional workspace credentials. Principal-bound API keys and Agent child keys live in the same regional data plane as their workspace. An API key secret can be exchanged for a short-lived, purpose-bound Loam JWT.
A workspace public ID identifies the provider and region. For example, rw-us-east1-ws-0195a3c47d3e7ab18c4f123456789abc belongs to the rw-us-east1 data plane at https://rw-us-east1.api.loamdata.com. Use the exact dataPlaneUrl returned for the workspace rather than the app domain or an organization slug.
Human requests
Send the Supabase access JWT as a Bearer token and put the full workspace public ID in the canonical request path. The path selects the workspace but never grants access; the regional data plane verifies the JWT and evaluates its current local grants.
curl https://rw-us-east1.api.loamdata.com/v1/workspaces/rw-us-east1-ws-0195a3c47d3e7ab18c4f123456789abc/nodes -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN"Create a regional user API key
In the Loam app, open the workspace and go to Settings > Personal API keys. The app sends credential operations directly to the workspace's dataPlaneUrl. You can also use the regional credential API with a human Supabase access JWT:
curl -X POST https://rw-us-east1.api.loamdata.com/v1/workspaces/rw-us-east1-ws-0195a3c47d3e7ab18c4f123456789abc/credentials -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" -H "Content-Type: application/json" -d '{
"kind": "api_key",
"principalId": "018f5f65-4a8c-7c2e-a4d9-7c7bf7e43104",
"name": "Warehouse reader",
"nodeRules": [
{
"effect": "allow",
"resourceId": "rw-us-east1-node-0195a3c47d3e7ab18c4f123456789abd",
"role": "viewer"
},
{
"effect": "deny",
"resourceId": "rw-us-east1-node-0195a3c47d3e7ab18c4f123456789abe"
}
]
}'Namespace, Table, and View permissions all use the same regional node public-ID shape. The node ID remains stable when its name or an ancestor Namespace changes. Permission rules do not include a resource type, Namespace path, or ancestor list, and bare internal UUIDs are not accepted at this public boundary. API key secrets look like loam_..., are shown once, and are stored only as hashes. Store the returned secret securely.
List, inspect, update, and revoke API-key authenticators through the workspace-scoped routes on that same regional host:
GET /v1/workspaces/{workspacePublicId}/credentials
POST /v1/workspaces/{workspacePublicId}/credentials
GET /v1/workspaces/{workspacePublicId}/credentials/{authenticatorId}
PATCH /v1/workspaces/{workspacePublicId}/credentials/{authenticatorId}
DELETE /v1/workspaces/{workspacePublicId}/credentials/{authenticatorId}These human management requests require the Supabase access JWT. The workspace path identifies their scope; they are not control-plane organization operations.
An authenticator is bound to exactly one current principal. Ordinary grants belong to that human or Agent, never to the key. Optional node and capability ceilings can narrow current authority but cannot add, copy, or widen it. The human who created an Agent key is provenance, not the key's authority or runtime actor.
Use a service-account child key for a production workload
A user API key remains a live subset of its creating human. For a minisite, background job, shared integration, or autonomous agent that must survive staff changes, create a workspace-owned service account and issue one independently rotatable child key per site and environment. Its root account owns capabilities, grants, Namespaces, quota, and audit identity; the child key is only an authenticator and may narrow but never widen the root.
Service-account keys use the workspace-scoped regional /v1/workspaces/{workspacePublicId}/token/exchange endpoint and 15-minute machine JWT lifecycle described below. Every operation rechecks the current account, child key, operation capability, exact service-account grant, explicit deny, and workspace entitlement. They do not inherit Everyone. An Agent inherits group grants only through explicit Agent permission-group membership. Read the service account setup guide for creation, one-time delivery, rotation, and human use-as access.
Use an analysis token for one temporary Namespace
A service-account child key or human use-as identity with file_analysis can create an ordinary private temporary Namespace. That no-store response reveals one opaque loam_an_v1_... analysis token exactly once. Send it through the ordinary Authorization: Bearer header from a browser, Worker, direct API client, CLI, or MCP session. It does not require the originating Agent JWT as a second credential.
The analysis token is confined to the exact Namespace node recorded by Loam. Its stable node ID, Table node IDs, import IDs, and run IDs identify resources but grant no authority. Every request live-rechecks the token, temporary Namespace, Agent, originating child key or durable direct or group human-use eligibility, exact grant, resource ceiling, entitlement, and denies. Revocation, narrowing, suspension, grant removal, deletion, or expiry therefore takes effect on the next request.
Treat the token as a scoped secret. Browser clients retain it only in memory; Workers may keep it server-side behind an application-local handle; MCP servers keep it in MCP session state. Never put it in a URL, filename, local or session storage, referrer, log, telemetry event, or error. See file analysis for repeated imports, ordinary SQL, and Namespace lifecycle.
Exchange an API key secret
Each personal API key belongs to exactly one workspace. Send it to POST /v1/token/exchange on its regional data plane to discover that workspace and receive a short-lived session. Callers that already know the workspace can instead use the compatibility POST /v1/workspaces/{workspacePublicId}/token/exchange. Service-account child keys require the workspace-scoped form.
curl -X POST https://rw-us-east1.api.loamdata.com/v1/token/exchange -H "Content-Type: application/json" -d '{"token": "loam_your_api_key_secret_here"}'Response:
{
"jwt": "eyJhbGciOiJSUzI1NiIs...",
"workspaceId": "rw-us-east1-ws-0195a3c47d3e7ab18c4f123456789abc",
"dataPlaneUrl": "https://rw-us-east1.api.loamdata.com",
"expiresAt": "2026-08-01T12:15:00Z"
}The JWT identifies the exact regional authenticator, effective principal, and workspace and is valid only for its intended data API audience. It contains no grants, denies, resource IDs, capabilities, or permission snapshot. A service-account JWT names both the Agent and child-key IDs; a personal key authenticates its bound human. Do not use either for organization, team, billing, credential, or grant management.
Use the machine JWT
Use the returned dataPlaneUrl for data operations, and send the short-lived JWT to the path containing the returned workspaceId:
curl https://rw-us-east1.api.loamdata.com/v1/workspaces/rw-us-east1-ws-0195a3c47d3e7ab18c4f123456789abc/nodes -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."MCP uses one configured resource
Loam exposes one configured MCP resource. Use the exact URL shown by Loam or returned by protected-resource discovery. Do not construct a hostname or append a workspace, Agent, or credential ID.
https://www.loamdata.com/mcpAfter sign-in, OAuth presents My account and every currently eligible Agent. Approval binds the authorization to the selected workspace and principal. Alice and Bob receive independent authorizations and refresh families when they select the same Agent.
/.well-known/oauth-protected-resource/mcp
/.well-known/oauth-authorization-server
/oauth/register
/oauth/authorize
/oauth/token
/oauth/revokeEvery tool call rechecks the selected principal's live grants, groups, capabilities, entitlement, Agent-use eligibility, and any authorization ceiling. The human's personal authority never unions into the Agent's authority.
JWT lifecycle
- API key JWTs expire after 15 minutes.
- The
expiresAtfield tells you exactly when. Refresh about 2 minutes before expiry. - A
401 Unauthorizedcan mean the JWT or API key is missing, malformed, invalid, expired, or revoked. Exchange the API key secret for a fresh JWT and retry once. - Revoking an API key blocks future exchanges. Previously issued JWTs fail live authorization on their next operation. Revoking one service-account child key leaves sibling keys and the root account active; suspending the root blocks them all.
Auto-refresh in TypeScript
const API_KEY = process.env.LOAM_API_KEY!;
const REGIONAL_PLANE_URL = "https://rw-us-east1.api.loamdata.com";
let cached: {
jwt: string;
workspaceId: string;
dataPlaneUrl: string;
expiresAt: number;
} | null = null;
async function getAccess() {
if (!cached || Date.now() > cached.expiresAt - 2 * 60 * 1000) {
const res = await fetch(`${REGIONAL_PLANE_URL}/v1/token/exchange`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ token: API_KEY }),
});
if (!res.ok) {
throw new Error(`Token exchange failed: ${res.status}`);
}
const data = await res.json();
if (data.dataPlaneUrl !== REGIONAL_PLANE_URL) {
throw new Error("Token exchange returned unexpected workspace placement");
}
cached = {
jwt: data.jwt,
workspaceId: data.workspaceId,
dataPlaneUrl: data.dataPlaneUrl,
expiresAt: new Date(data.expiresAt).getTime(),
};
}
return cached;
}
async function query(sql: string) {
const access = await getAccess();
const res = await fetch(
`${access.dataPlaneUrl}/v1/workspaces/${encodeURIComponent(access.workspaceId)}/query`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${access.jwt}`,
},
body: JSON.stringify({ sql, format: "json" }),
},
);
if (!res.ok) {
throw new Error(`Query failed: ${res.status} ${await res.text()}`);
}
return res.json();
}Auto-refresh in Python
import os
import time
import requests
API_KEY = os.environ["LOAM_API_KEY"]
REGIONAL_PLANE_URL = "https://rw-us-east1.api.loamdata.com"
_cached = None
def get_access():
global _cached
if _cached is None or time.time() > _cached["expires_at"] - 120:
resp = requests.post(
f"{REGIONAL_PLANE_URL}/v1/token/exchange",
json={"token": API_KEY},
)
resp.raise_for_status()
data = resp.json()
if data["dataPlaneUrl"] != REGIONAL_PLANE_URL:
raise RuntimeError("Unexpected workspace placement")
from datetime import datetime
_cached = {
"jwt": data["jwt"],
"workspace_id": data["workspaceId"],
"data_plane_url": data["dataPlaneUrl"],
"expires_at": datetime.fromisoformat(
data["expiresAt"].replace("Z", "+00:00")
).timestamp(),
}
return _cached
def query(sql):
access = get_access()
resp = requests.post(
f'{access["data_plane_url"]}/v1/workspaces/{access["workspace_id"]}/query',
json={"sql": sql, "format": "json"},
headers={"Authorization": f'Bearer {access["jwt"]}'},
)
resp.raise_for_status()
return resp.json()Token exchange errors
Token exchange authenticates the API key and issues a regional JWT. Each later operation separately checks the current capability, data grants, and product entitlement. A successful exchange can therefore be followed by 403 when the requested operation exceeds the credential's live authority.
400: Malformed request or a workspace public ID for another region.401: Empty, invalid, expired, or revoked API key secret.422: Missingtokenfield or invalid field type, or an unknown request field.500or503: Regional service failure. Retry after a short delay.
Last updated 2026-08-23