Concepts
Identity and tokens
Every call to dabadata is made by someone. Who that is decides which rules apply, and it is written into the trace next to the call. This page covers the four kinds of caller - project key, user token, view token and anonymous - and the ways your app's users get a token.
Four ways in for your app's users
A person using your app reaches dabadata in one of four ways. They can be combined; whichever way a call comes in, the rules see the same { id, role, name }.
- Tokens minted by your backend. Your server signs people in its own way, then calls
POST /v1/users/tokenwith the project key (on a hosted project, its secret key) and hands the pair to the client. See User tokens. - Your identity provider's JWTs. Declare Google, Clerk, Auth0, Firebase Auth, Supabase Auth or your own issuer in the definition, and the client sends the provider's token as it is - or exchanges it once for a dabadata pair. See Your own identity provider.
- Anonymous. No token at all: the caller may do what the rules allow
anyone. - Hosted accounts. dabadata keeps your users' accounts itself - sign-up, sign-in, email links, password reset, hosted pages and
db.authin the client - when the definition turns onauth.users. People can also sign in with Google, Apple, Microsoft, GitHub and more. This is also how people sign in to your app inside ChatGPT and Claude. See Sign-in for your app's users.
Choosing how people sign in
| Way in | Who keeps the accounts | Setup | Best for |
|---|---|---|---|
| Hosted accounts: email and password, sign-in links | dabadata, per environment | Small: turn on auth.users, then the hosted pages or db.auth | An app with no sign-in yet, and no backend |
| Social sign-in through hosted accounts: Google, Apple, Microsoft, GitHub, ... | dabadata; the provider vouches for the email | Medium: an OAuth app at each provider, then its client id and secret in the console | People who would rather not make another password; GitHub or GitLab for developer tools, Microsoft or Slack for work tools |
| A provider's JWT: Clerk, Auth0, Firebase Auth, Supabase Auth, Google or Apple ID tokens, your own issuer | The provider | Small if you have one: declare it in auth.issuers | An app that already has sign-in, or needs what a dedicated auth product offers |
| Tokens minted by your backend | Your backend | Medium: a server route that calls POST /v1/users/token | An app with its own server and login, such as a WordPress site |
| Anonymous | Nobody | None | Public reads, demos, a form anyone may fill in |
They combine: hosted accounts and auth.issuers can be on at once, and the rules see a role either way. Firebase Authentication and Supabase Auth keep accounts for you the way hosted accounts do, and their social sign-in also goes through OAuth apps you register at each provider. Clerk and Auth0 are dedicated sign-in products, with their own UI components and features such as organizations and multi-factor authentication. If you use or need one of these, keep it and declare it as an issuer; dabadata checks its tokens as they are.
Four kinds of caller
| Caller | Sends | Is, to the rules | Rules |
|---|---|---|---|
| Project key | Authorization: Bearer <project key>; on a hosted project its secret key, sk_... | { id: "service", role: "service" } | Bypassed. Recorded as bypass. |
| User token | Authorization: Bearer <user token>, or a JWT from a provider the project trusts | The { id, name, role } it was minted for, or read from the JWT | Applied. |
| View token | Authorization: Bearer <view token> or ?token= | The viewer | Reads bypass; writes are refused. |
| Anonymous | No token at all | { id: "", role: "anonymous" } | Applied. Passes only anyone and { role: "anonymous" }. |
A token may be sent as a Bearer header or as a ?token= query parameter (the query parameter wins when both are present). The WebSocket uses the query parameter, since browsers cannot set headers on it. The project key is only accepted as a Bearer header.
A token that does not match anything - unknown, expired, or from another project - is a 401, not an anonymous call. Only a request with no token at all is anonymous.
On a hosted project the secret key of an environment is its project key, and a publishable key (X-Api-Key or ?key=) tells the server which environment an anonymous call or a provider JWT is for. The publishable key adds no rights: the caller is still one of the four above. See Projects and keys.
What each may do
| Endpoint | Project key | View token | User token | Anonymous |
|---|---|---|---|---|
GET /v1/health | ✓ | ✓ | ✓ | ✓ |
GET /v1/schema | ✓ | ✓ | ✓ | 401 |
POST /v1/schema/reload | ✓ | 401 | 401 | 401 |
PUT /v1/schema, /v1/schema/versions, /v1/schema/rollback | ✓ | 401 | 401 | 401 |
POST /v1/users/token | ✓ | 401 | 401 | 401 |
POST /v1/users/refresh | No auth header: the refresh token in the body is the credential. | |||
POST /v1/users/exchange | No auth header: the provider JWT in the body is the credential. | |||
POST /v1/users/revoke | ✓ | 401 | 401 | 401 |
GET /v1/users | ✓ | ✓ | 401 | 401 |
POST /v1/mutate | bypass | 403 | rules | rules |
GET /v1/db/... | bypass | bypass | rules | rules |
GET /v1/trace | ✓ | ✓ | 401 | 401 |
POST /v1/simulate | ✓ | ✓ | 401 | 401 |
POST /v1/view-token | ✓ | 401 | 401 | 401 |
GET /v1/live | bypass | bypass | rules | rules |
POST /v1/demo/tokens | No auth, and only in demo mode (404 otherwise). | |||
/v1/project/... | ✓ | 401 | 401 | 401 |
GET /v1/meta | ✓ | ✓ | ✓ | ✓ |
The project API (/v1/project/...) belongs to hosted projects; the default project has only its export. "rules" means the call is allowed in principle and the table's rules decide row by row. A provider JWT is treated exactly like a user token. A view-token socket (and a project-key socket) also receives every new trace row and the WordPress mirror events, which is how the viewer stays live.
User tokens
A user token stands for one person in your app. Your backend mints it with the project key after its own sign-in, and hands the result to that person's client:
// On your server, after your own login check:
const res = await fetch("https://dabadata.com/v1/users/token", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DABADATA_SECRET_KEY}`, // the project key: on a hosted project, the secret key
"Content-Type": "application/json",
},
body: JSON.stringify({ id: user.id, name: user.name, role: "editor" }),
});
const { token, expiresAt, refreshToken, refreshExpiresAt } = await res.json();idis your own id for the person, a non-empty string. It is whatownerrules anduserIdcolumns hold.rolemust be one of the project's declared roles.nameis optional and only for display (the trace andGET /v1/usersshow it).
One call mints a pair, the way Firebase pairs a 1-hour ID token with a refresh token: token is the access token the client sends on every call and lives 1 hour; refreshToken lives 30 days and is only good for getting the next pair. Both are opaque 64-character hex strings. A leaked access token is useful for an hour at most, and a client that keeps running refreshes by itself.
The pair is fixed to that id, name and role, as stored when it was first minted; refreshing keeps them. To change someone's role, mint a new pair and revoke the old ones.
Refreshing
- Mint. Your server calls
POST /v1/users/tokenand passes all four fields to the client. - Use. The client sends
tokenasAuthorization: Bearer(or?token=on the socket). - Refresh. Before
expiresAt, or when a call answers 401, the client sends{ "refreshToken": "..." }toPOST /v1/users/refresh. No Authorization header is needed; the refresh token is the credential. The answer is a new pair of the same shape for the same user. - Rotate. The refresh token just used is deleted and the new one is valid for 30 days from now. That makes the 30 days sliding: someone who comes back within 30 days stays signed in. Access tokens the user already holds keep working until their own expiry.
A refresh token works once. An unknown, expired, already used or revoked one - or one from another project - answers 401, and the client has to go back to your server for a new pair. Even if two calls race with the same refresh token, one gets the new pair and the other a 401. The JavaScript client does all of this for you when you give it the refresh token.
Sockets are checked when they open. An open socket is not closed when its access token later expires or is revoked; it keeps the identity it opened with until it closes. Every new connection, reconnects included, needs a currently valid access token: an expired one gets a 401 on the upgrade.
Your own identity provider
You need neither dabadata's hosted accounts nor your backend minting every token. If your users already sign in with Google, Apple, Clerk, Auth0, Firebase Auth, Supabase Auth or Keycloak - or your backend issues its own JWTs - declare that provider in the project file. The client then sends the provider's JWT straight to dabadata, as a Bearer header or ?token=; dabadata checks it and maps it onto { id, role, name }.
The auth block
The optional auth block of the project file lists the issuers the project trusts. It is validated with the rest of the file - a bad block is a list of messages, like a bad table - and stays plain JSON.
export default defineProject({
name: "default",
roles: ["admin", "editor", "viewer"],
auth: {
issuers: [
{ name: "google", issuer: "https://accounts.google.com", audience: "<oauth client id>",
defaultRole: "viewer" },
{ name: "app", issuer: "https://auth.example.com", jwksUrl: "https://auth.example.com/jwks.json",
audience: ["dabadata"], roleClaim: "realm_access.roles", roleMap: { "app-editor": "editor" },
defaultRole: "viewer" },
],
},
tables: { /* ... */ },
});| Field | Meaning |
|---|---|
name | Your label for the issuer, ^[a-z][a-z0-9_]{0,30}$, unique. The trace shows it. |
issuer | Required. Must equal the token's iss exactly (mind a trailing /). |
audience | Required. A string or a list; the token's aud must contain one of them. |
jwksUrl | Where the provider publishes its public keys. Leave it out and dabadata uses OIDC discovery: <issuer>/.well-known/openid-configuration, then its jwks_uri. |
secretEnv | The default project only (a self-hosted server's own project). Instead of keys: the name of an environment variable holding an HS256 secret (at least 32 characters). |
secret | Hosted projects only. Instead of keys: the name of a project secret holding the HS256 key (at least 32 characters). |
idClaim | The claim that becomes the user id. Default sub. |
nameClaim | The claim that becomes the display name. Default name; optional in the token. |
roleClaim | Optional. The claim holding a role or a list of roles. A dotted path works (realm_access.roles); a name with :// is one literal key (https://example.com/role). |
roleMap | Optional. Claim value to declared role, e.g. { "owner": "admin" }. A value that is already a declared role needs no entry. |
defaultRole | Required, a declared role: what a user gets when no role claim value maps. |
Set at most one of jwksUrl, secretEnv and secret; none means OIDC discovery. With a list of roles in the claim, the first value that maps wins. Roles always end up as one of your declared roles, so no token can make anyone the project key or the viewer.
Example: Google sign-in
A Google ID token has iss https://accounts.google.com, your OAuth client id as aud, a stable sub and the person's name. Google publishes its keys through discovery, so the block needs no key settings, and since the token carries no role, everyone gets defaultRole:
auth: {
issuers: [
{ name: "google", issuer: "https://accounts.google.com",
audience: "1234567890-abc.apps.googleusercontent.com", defaultRole: "editor" },
],
},The browser hands the ID token from Google's sign-in button to dabadata as it is:
const res = await fetch("https://dabadata.com/v1/db/tasks", {
headers: { Authorization: `Bearer ${googleIdToken}`, "X-Api-Key": "pk_live_EXAMPLEexample0000000000" },
});The same shape fits the other hosted providers; what changes is issuer, audience and where the roles are. Check one real token from your provider (its payload is readable base64) before you rely on these:
| Provider | issuer | audience |
|---|---|---|
| Apple | https://appleid.apple.com | Your bundle id or Services ID. |
| Auth0 | https://<tenant>.auth0.com/ (trailing /) | Your API identifier. |
| Clerk | Your Frontend API URL | Use a JWT template that sets aud; plain session tokens carry none. |
| Firebase Auth | https://securetoken.google.com/<project id> | Your Firebase project id. |
| Supabase Auth | https://<ref>.supabase.co/auth/v1 | authenticated. Older projects sign with a shared secret: use secretEnv (hosted: secret). |
| Keycloak | https://<host>/realms/<realm> | Your client id (add an audience mapper); roles in realm_access.roles. |
Example: your own HS256 tokens
A backend that already signs its own JWTs with a shared secret can keep doing so. The secret lives in the server's environment - in .env or the container's environment - never in the project file, which only names the variable:
auth: {
issuers: [
{ name: "legacy", issuer: "my-backend", audience: "dabadata",
secretEnv: "LEGACY_JWT_SECRET", roleClaim: "role", defaultRole: "viewer" },
],
},// On your backend: a token dabadata accepts, signed with the same secret.
import { createHmac } from "node:crypto";
const part = (o: object) => Buffer.from(JSON.stringify(o)).toString("base64url");
const now = Math.floor(Date.now() / 1000);
const head = part({ alg: "HS256", typ: "JWT" });
const body = part({ iss: "my-backend", aud: "dabadata", sub: user.id, name: user.name,
role: "editor", exp: now + 3600 });
const sig = createHmac("sha256", process.env.LEGACY_JWT_SECRET!)
.update(`${head}.${body}`).digest("base64url");
const jwt = `${head}.${body}.${sig}`;The variable is read when the definition is loaded or reloaded: if it is missing or shorter than 32 characters, the definition does not load and the message says which issuer needs it.
On a hosted project
The auth block works the same on a hosted project, with three differences:
- It travels as JSON. A hosted project has no project file: the block is part of the definition you send with
PUT /v1/schema, to each environment that needs it. - Send the publishable key with provider JWTs. A JWT says nothing about which project it is for, so the client sends the environment's publishable key beside it -
X-Api-Keyon HTTP,?key=on the socket, and withPOST /v1/users/exchangetoo. Without a key the JWT goes to the server's default project, not yours. - Keys are fetched from public HTTPS addresses only.
jwksUrl, the issuer's discovery document and thejwks_uriit names must behttps://URLs whose host resolves to public addresses only - no loopback, private, link-local or cloud metadata addresses - and redirects are not followed. A fetch that breaks this rule fails like an unreachable provider: every token of that issuer is refused (the server log says why). Validation checks only that the URL is http(s), so a definition with anhttp://or internaljwksUrlis accepted and then verifies nothing. A self-hosted server does not have this rule, so it can use an internal Keycloak.
HS256 on a hosted project
A hosted project cannot read the server's environment, so secretEnv is refused there (secretEnv is not available on hosted projects - store the key with PUT /v1/project/secrets/<NAME> and use secret). Store the shared secret as a project secret first, then name it in the issuer with secret:
curl -X PUT https://dabadata.com/v1/project/secrets/LEGACY_JWT_SECRET \
-H "Authorization: Bearer $DABADATA_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"value":"<the same secret your backend signs with>"}'"auth": {
"issuers": [
{ "name": "legacy", "issuer": "my-backend", "audience": "dabadata",
"secret": "LEGACY_JWT_SECRET", "roleClaim": "role", "defaultRole": "viewer" }
]
}The order matters: PUT /v1/schema checks that the secret exists in that environment and is at least 32 characters, and refuses the definition otherwise (secret LEGACY_JWT_SECRET is not set (store it with PUT /v1/project/secrets/LEGACY_JWT_SECRET)). Live and test have separate secrets. The token itself is signed exactly as in the example above. Replacing the secret takes effect within a minute; deleting it makes the issuer refuse every token.
How a token is checked
For every call dabadata works out who is calling in this order: the project key, a view token, a user token, and only then a provider JWT. A token that looks like a JWT (three base64url parts whose header names an alg) is checked against every declared issuer whose issuer equals its iss:
- The algorithm fits the issuer:
RS256orES256for keys from a JWKS,HS256only withsecretEnvorsecret. Anything else,noneincluded, is refused. - The signature verifies against the issuer's keys. Keys are fetched on the first token, kept for 10 minutes, and a token with an unknown
kidmakes dabadata fetch them again (at most once a minute per issuer), so key rotation just works. expis present and not past,nbf(if present) is reached - both with 60 seconds of clock skew - andaudcontains one of the declared audiences.- The id claim is present.
A JWT that fails any check is a 401, exactly like a bad token; the reason is not sent back. Nothing is stored: a provider JWT is checked on every call and never lands in user_tokens. The WebSocket accepts one as ?token= too, and like any socket keeps the identity it opened with until it closes. Ids are not namespaced by issuer, so two issuers that hand out the same id are the same person to owner rules.
Exchanging a provider token
Instead of sending the provider's JWT on every call, a client can trade it once for a dabadata pair with POST /v1/users/exchange and carry on with dabadata's refresh model. No Authorization header is needed; the JWT in the body is the credential:
curl -X POST https://dabadata.com/v1/users/exchange \
-H "X-Api-Key: pk_live_EXAMPLEexample0000000000" \
-H "Content-Type: application/json" \
-d '{"jwt":"<provider JWT>"}'The answer has the same shape as POST /v1/users/token - token, expiresAt, refreshToken, refreshExpiresAt - for the identity in the JWT, issuer included. From then on dabadata's lifetimes apply, not the JWT's exp. Exchanged users are listed by GET /v1/users and signed out of dabadata by revoking them; a bad JWT is a 401.
What the trace shows
Every call made with a provider JWT, or with a pair exchanged for one, is traced with the issuer that vouched for the caller:
"user": { "id": "109876543210", "role": "editor", "name": "Dana", "issuer": "google" }Two things work as before. GET /v1/users lists token-minted users only, so someone who only ever sends provider JWTs directly is not in it - look for them in the trace. And POST /v1/simulate still takes { id, role }: simulate the role a provider user maps to.
View tokens
A view token is for the viewer (the inspect app at /) and for tools that need to watch everything. It reads every table, the schema, the user list and the trace with a bypass, may run simulations and opens a socket that streams the trace - but it can never write: /v1/mutate answers 403 view tokens are read-only. Mint one with POST /v1/view-token; the WordPress plugin does this for its admin page.
Lifetimes
| Token | Valid for | Stored in |
|---|---|---|
| User (access) token | 1 hour | user_tokens |
| Refresh token | 30 days, renewed on every refresh | refresh_tokens |
| View token | 12 hours | view_tokens |
| Provider JWT | Its own exp (60 seconds of skew), set by the provider | Not stored |
| Project key | Until you change it in .env | projects |
| Hosted publishable and secret keys | Until rotated, then 24 more hours | project_keys (secret keys as a hash only) |
dabadata's own lifetimes are fixed; there is no setting for them. Tokens are stored on the server. Expired ones are deleted the next time they are presented, and whenever a new pair is minted. A view token has no refresh: mint a new one before it runs out.
Revoking
To sign someone out everywhere, call POST /v1/users/revoke with the project key and their id. It deletes every access token and every refresh token of that user in the project and tells you how many:
curl -X POST https://dabadata.com/v1/users/revoke \
-H "Authorization: Bearer <project key>" \
-H "Content-Type: application/json" \
-d '{"id":"bob"}'{ "ok": true, "revoked": { "tokens": 2, "refreshTokens": 1 } }From then on every call with those tokens is a 401 and no refresh works. A socket that is already open keeps going until it closes, since sockets are checked when they open. A view token is revoked by deleting its row from view_tokens.
The project key
The project key is the project's root password - on a hosted project, each environment's secret key. It applies the definition, mints every kind of token and passes every rule. dabadata records its calls in the trace as bypass with the user service, so you can always see what it did - but nothing stops it.