dabadataDocs
Concepts / Sign-in for your app's users

Concepts

Sign-in for your app's users

A project can let dabadata keep the accounts of its own users: sign up with an email and a password, sign in, sign-in links by mail, password resets, with hosted pages if you want them and db.auth in the JavaScript client if you do not. You need no auth provider and no backend of your own. A signed-in user gets an ordinary dabadata user token, so rules, the trace and live subscriptions work exactly as with any other token.

What you get

  • Accounts per environment. Live and test each have their own users; an account on test does not exist on live. These are your app's users, never people with a console login (Accounts and teams).
  • Normal tokens. Signing in answers the same pair as POST /v1/users/token: an access token for 1 hour and a refresh token for 30 days, for the user { id, name, role, issuer: "hosted" }. Ids look like usr_k3x9m2p7q4w8z1ab. Refreshing works as always.
  • Hosted pages at /auth/<project id>/ for sign in, sign up, links and resets, or the JS client to build your own forms.
  • Social sign-in with Google, Apple, Microsoft, GitHub and more, through your own OAuth apps, when you add providers (Sign in with Google, Apple and more).
  • A role per user, one of your project's roles. New users get defaultRole; you change it in the console or with the secret key.

Turn it on

Add an auth.users block to the environment's definition, next to tables:

json
"auth": {
  "users": {
    "enabled": true,
    "signup": "open",
    "defaultRole": "editor",
    "verifyEmail": true,
    "redirectUrls": ["http://localhost:5173/", "https://tasks.example.com/"]
  }
}
KeyDefaultMeaning
enabledfalseOnly true switches the routes and the hosted pages on. Off, every route and page answers 404.
signup"open""open": anyone may sign up. "invite": only people you add (Managing users); sign-up answers 403 signup_closed.
defaultRolerequiredA role the definition declares. Every new user gets it.
verifyEmailtrueSign-in waits until the address is confirmed by a mailed link. false: sign-up signs in at once.
redirectUrls[]Where links and hosted pages may send people back: absolute http(s) URLs without a #, at most 20. Anything else asked for is refused.
invitenone{ rule, roles? }: which signed-in users may invite people from your app, and which roles they may give (Invite people from your app). Without it nobody invites from the app.
managenoneA rule: which signed-in users may list, change and remove users from your app (App admins). Without it nobody manages users from the app.
manageRolesevery roleWith manage: the roles a manager may give, and the roles of the users they may change.
profilesnone{ read, fields? }: a read-only table users with every user's id, name (and role, if you ask) that rows and pages can show (Profiles).
onSignUpnone{ table, values }: one row the server inserts whenever a user is created (A row for every new user).
providersnoneSign-in with Google, Apple, Microsoft, GitHub, GitLab, Discord, LinkedIn and Slack, through your own OAuth app at each: { google: { clientId, secret }, ... } (Sign in with Google, Apple and more).

Unknown keys are errors. auth.issuers may sit beside users or be left out. Edit it on the console's Definition page, or apply it with the environment's secret key: PUT /v1/schema takes the whole definition, so send the tables too - here the tasks template every new account starts with:

bash
curl -X PUT https://dabadata.com/v1/schema \
  -H "Authorization: Bearer $DABADATA_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<'EOF'
{ "definition": {
    "roles": ["admin", "editor", "viewer"],
    "tables": {
      "tasks": {
        "columns": { "title": { "type": "text" }, "done": { "type": "bool", "default": false },
                     "owner": { "type": "userId" }, "due": { "type": "date", "optional": true },
                     "notes": { "type": "json", "optional": true } },
        "rules": { "read": "authenticated", "create": { "role": ["admin", "editor"] },
                   "update": { "any": [{ "role": "admin" }, { "owner": "owner" }] }, "delete": { "role": "admin" } }
      }
    },
    "auth": { "users": { "enabled": true, "defaultRole": "editor",
                         "redirectUrls": ["http://localhost:5173/"] } }
  }
}
EOF

Do it per environment: test first, live when you are ready. The console's Users page shows the block while hosted accounts are off, and the list of users once they are on.

The routes

Every route is under /v1/auth/users/ and needs only the environment's publishable key (X-Api-Key); me, signout, the invite routes and the admin/ routes also need the user's token. Bodies are JSON.

RouteBodyAnswer
POST signup{ email, password, name?, redirectUrl? }{ ok, verify: true, signupSecret } and a confirmation mail; with verifyEmail: false, { ok, verify: false, tokens, user }
POST verify{ token, signupSecret? }{ tokens, user, redirectUrl }
POST signin{ email, password }{ tokens, user }
POST magic{ email, redirectUrl? }{ ok: true }, and a 15-minute sign-in link by mail
POST magic/verify{ token }{ tokens, user, redirectUrl }
POST forgot{ email, redirectUrl? }{ ok: true }, and a 1-hour reset link
POST reset{ token, password }{ tokens, user, redirectUrl }; every earlier token of the user stops working
GET me-{ user }
PATCH me{ name?, password?, currentPassword? }{ user }, plus new tokens after a password change
POST signout{ refreshToken? } or { everywhere: true }{ ok, revoked: { tokens, refreshTokens } }
POST invite{ email, name?, role?, redirectUrl? }{ ok: true }, whether or not the address has an account (invites)
GET invites-{ invites: [{ id, email, name, role, createdAt, expiresAt }] }: the caller's open invites
DELETE invites/:id-{ ok: true }: cancels one of them
GET admin/users-{ users, next }: the users, for app admins (?query=&limit=&after=)
PATCH admin/users/:id{ role?, name?, disabled? }{ user }
DELETE admin/users/:id-{ ok, revoked }

user is { id, email, name, role, emailVerified, hasPassword, createdAt }. hasPassword is false for a user who signed in by link or with a provider and never set a password: show them a "set a password" form if your app wants one. Passwords follow the console's rules: 10 to 200 characters, not one character repeated, not the email. signup, magic and forgot answer the same whether or not the address has an account; a known address that signs up again gets a "you already have an account" mail with a sign-in link, never a second account. Every route, with its errors, is in the HTTP API reference.

Hosted pages

With users on, the environment has sign-in pages on the API's host, titled with your project's name (rename it to your app's name):

text
https://dabadata.com/auth/prj_example12345/           sign in (live)
https://dabadata.com/auth/prj_example12345_test/      sign in (test)
https://dabadata.com/auth/prj_example12345/signup     sign up
https://dabadata.com/auth/prj_example12345/magic      email me a sign-in link
https://dabadata.com/auth/prj_example12345/forgot     forgot password

With providers set, the sign-in and sign-up pages also show a "Continue with Google" (GitHub, ...) button for each provider that is on; it keeps the page's ?redirect=. The verify, reset and invite views are where mailed links land; they show a button, so a mail scanner that opens the link does not use it up. Add ?redirect=<one of your redirectUrls> and, after a successful sign-in, the page sends the person there with the tokens in the URL fragment:

text
https://tasks.example.com/#token=...&refreshToken=...&expiresAt=...&refreshExpiresAt=...

Call db.auth.completeLink() on that page and the client takes the tokens and clears the fragment. Without a redirect the page simply says "You are signed in". The pages are plain HTML with one script and no third-party code, under a strict Content Security Policy; they cannot be framed and send no referrer.

Your app's name and logo in mails and pages

Every mail your app's users get (sign-in links, confirmations, password resets, invitations) and every hosted page is titled with the project's name, not your site's name or domain: "Sign in to my-first-app", with the first letter in a square as the logo. A new project is called my-first-app or whatever you named it when you created it, so rename it to the name your users know before you invite anyone:

  • In the console: Settings > Project > General, change the name, save.
  • Or with the secret key: PATCH /v1/project { "title": "TripMates" } (1 to 80 characters).

The name is shared by live and test and takes effect for the next mail. Only the title changes: the project id, the keys and the site's address stay the same. Mails come from dabadata's no-reply address; the name is in the subject, the heading and the footer ("Sent by dabadata on behalf of TripMates").

Add a logo and it replaces the letter: next to the name at the top of every mail, and above the name on the hosted pages. A square PNG or JPEG, 128 to 512 px, at most 256 KB, shared by live and test:

  • In the console: Settings > Project > General > Upload logo (Replace logo, Remove).
  • Or with the secret key: PUT /v1/project/logo { "contentType": "image/png", "data": "<base64>" }; DELETE /v1/project/logo removes it.

The logo is served publicly at /v1/logos/<project id>/<hash> (the project's logoUrl); a new logo gets a new address, so mails already sent keep showing the logo they were sent with until it is replaced. Mail apps that block images until the reader allows them show the name's first letter instead. While the project is still called my-first-app the console says so on Overview, Authentication and Sites, and Authentication > Sign-in methods shows the mail's header as your users will see it.

Redirect URLs and app mode

A link in a mail goes to one of two places:

redirectUrl you sendThe link in the mailThen
none, or a listed URLThe hosted page: /auth/<env>/verify?token=...The person presses the button; the page signs them in and sends them to redirectUrl with the tokens, if there is one.
a listed URL plus ?mode=appYour page: <listed URL>#token=...&type=verifyYour page posts the token itself: db.auth.completeLink() does it. type is verify, magic, reset or invite.

The URL without mode=app must be in redirectUrls exactly (the query compared in normal form), else the call is a 400 whose message lists the URLs that are allowed, e.g. redirectUrl is not one of the project's auth.users.redirectUrls (listed: http://localhost:5173/). http://localhost:5173/ and http://127.0.0.1:5173/ are different URLs: list the one your dev server opens. The page's origin must also be in the environment's allowed origins, or the browser blocks the call before you can read that message. The console's Authentication - URL configuration - App URLs sets both for an address in one step. A link answers the redirectUrl it was requested with only if that URL is still listed when it is used.

The JavaScript client

Give createClient the environment's publishable key, and db.auth does the rest: it calls the routes, stores the tokens, refreshes them and reconnects your live subscriptions as the new user.

MethodDoes
signUp({ email, password, name?, redirectUrl? })Creates the account. Resolves { user, verify, signupSecret }: with verify a confirmation link is on its way and user is null; without, the person is signed in.
signIn({ email, password })Signs in and resolves the user.
signInWithLink({ email, redirectUrl? })Mails a 15-minute sign-in link.
providers(), signInWithProvider(provider, { redirectUrl? })Which providers are on, and leaving the page to sign in with one (Sign in with Google, Apple and more).
signInWithIdToken({ provider, idToken, nonce?, name? })Signs in with a Google or Apple ID token your app got itself (native apps).
completeLink({ url?, password?, signupSecret? })Finishes whatever #token= the page URL carries: the hosted pages' redirect, or an app-mode link. Resolves the user, or null when there is no token. Reset and invite links need password (else it rejects with code: "password_required" and keeps the link).
resetPassword({ email, redirectUrl? })Mails a 1-hour reset link.
signOut({ everywhere? })Signs out this session (or all of the user's), forgets the tokens and reconnects as anonymous.
user({ force? })The signed-in user, or null. Cached; force asks the server again.
update({ name?, password?, currentPassword? })Changes the signed-in user's name and / or password and resolves the user. A new password needs currentPassword when there is one; it signs every other session out, and the client keeps the new token pair it gets back, so this page stays signed in.
onAuthChange(cb)cb(user, event) on "signIn", "signOut", "refresh" and "update" (after update()). Returns the function that stops it.
invite({ email, name?, role?, redirectUrl? })Invites someone as the signed-in user (below).
invites()The signed-in user's open invites, newest first.
cancelInvite(id)Cancels one of them.
admin.listUsers({ query?, limit?, after? })For app admins: resolves { users, next }, newest first.
admin.setRole(id, role), admin.setName(id, name)Changes a user's role or name; resolves the user.
admin.disable(id), admin.enable(id)Disables a user (signed out everywhere) or lets them in again.
admin.remove(id)Deletes a user.

The session is kept in localStorage (option storage: "local", the default) and picked up by the next page load, or only in memory with storage: "memory". A refresh the server refuses ends the session and fires signOut. The client reference has every option.

A complete example

One HTML file: sign up, finish the mailed link, sign in, show who is signed in, sign out, and a task list that fills in once someone is signed in. It assumes the definition above on the test environment, with the page served at http://localhost:5173/ (any static server will do).

html
<!doctype html>
<meta charset="utf-8">
<title>Tasks</title>

<p id="who">Loading...</p>
<form id="signup">
  <input name="email" type="email" placeholder="Email" required>
  <input name="password" type="password" placeholder="Password, 10+ characters" minlength="10" required>
  <button>Sign up</button>
</form>
<form id="signin">
  <input name="email" type="email" placeholder="Email" required>
  <input name="password" type="password" placeholder="Password" required>
  <button>Sign in</button>
</form>
<button id="signout" hidden>Sign out</button>
<form id="add" hidden><input name="title" placeholder="New task" required> <button>Add</button></form>
<ul id="tasks"></ul>

<script src="https://dabadata.com/client/dist/dabadata.iife.js?v=5bce207b"></script>
<script>
  const db = dabadata.createClient({
    url: "https://dabadata.com",
    key: "pk_test_EXAMPLEexample0000000000", // your test publishable key
  });
  const $ = (id) => document.getElementById(id);

  function show(user) {
    $("who").textContent = user ? `Signed in as ${user.email} (${user.role})` : "Not signed in";
    $("signout").hidden = $("add").hidden = !user;
    $("signup").hidden = $("signin").hidden = !!user;
  }
  db.auth.onAuthChange((user) => show(user));

  // A mailed link (app mode) or the hosted pages brought us back with #token=...
  db.auth.completeLink()
    .then(() => db.auth.user())
    .then(show)
    .catch((err) => { alert(err.message); db.auth.user().then(show); });

  $("signup").onsubmit = async (e) => {
    e.preventDefault();
    const f = new FormData(e.target);
    try {
      const res = await db.auth.signUp({
        email: f.get("email"),
        password: f.get("password"),
        redirectUrl: location.origin + "/?mode=app", // the link comes straight back here
      });
      if (res.verify) alert("Check your mail and open the link in this browser.");
    } catch (err) { alert(err.message); }
  };

  $("signin").onsubmit = async (e) => {
    e.preventDefault();
    const f = new FormData(e.target);
    try {
      await db.auth.signIn({ email: f.get("email"), password: f.get("password") });
    } catch (err) {
      // invalid_login, email_unverified, user_disabled, rate_limited
      alert(err.code === "email_unverified" ? "Confirm your email first." : err.message);
    }
  };

  $("signout").onclick = () => db.auth.signOut();

  $("add").onsubmit = async (e) => {
    e.preventDefault();
    await db.insert("tasks", { title: new FormData(e.target).get("title") });
    e.target.reset();
  };

  // Live list. Signed out, the read rule ("authenticated") refuses it;
  // after a sign-in the client subscribes again as the user and the rows arrive.
  const tasks = new Map();
  const render = () => $("tasks").replaceChildren(
    ...[...tasks.values()].map((t) => Object.assign(document.createElement("li"), { textContent: t.title })));
  db.subscribe("tasks", {}, {
    onRows(rows) { tasks.clear(); for (const r of rows) tasks.set(r.id, r); render(); },
    onChange({ op, row }) { if (op === "delete") tasks.delete(row.id); else tasks.set(row.id, row); render(); },
    onError() { tasks.clear(); render(); },
  });
</script>

Open the page in two browsers with two accounts: a task added in one appears in the other. Each insert is in the trace with the user's id, their role editor and the issuer hosted.

The sign-up secret

Anyone can sign up with any address and a password of their choosing - including an address that belongs to someone else. So the password chosen at sign-up only survives if the confirmation comes from where the sign-up was made:

  • Every sign-up that waits for confirmation answers a signupSecret (and sets it as a cookie for the hosted pages). The client keeps it for 24 hours and completeLink() sends it with the confirmation link: the password and name stay.
  • If the address is first proven any other way - the confirmation link opened in another browser, a sign-in link, a wrong secret - the user is still confirmed and signed in, but without the password and name from the sign-up. Whoever signed up may not own the address. The user that comes back has hasPassword: false; offer a "set a password" form and call db.auth.update({ password }) (no currentPassword is needed while there is none), or let the person use "forgot password" later.
  • If your app carries the secret itself (say, to a native app), pass it: completeLink({ signupSecret }).
  • For the same reason the right password on an unconfirmed account answers email_unverified and mails nothing; the owner can always ask for a sign-in link.

Invite people from your app

Your app's own signed-in users can invite others, with only the publishable key in the browser and no backend of yours. You decide who may invite, and which roles an invite may give, in auth.users.invite:

json
"auth": {
  "users": {
    "enabled": true,
    "defaultRole": "viewer",
    "redirectUrls": ["http://localhost:5173/"],
    "invite": { "rule": { "role": ["admin", "editor"] }, "roles": ["viewer", "editor"] }
  }
}
  • rule is a rule like a table's ("authenticated", { "role": [...] }, any, all), checked against the inviter's current role. owner has no row to look at here and is refused. Without an invite block nobody invites from the app: 403 invite_not_allowed, the same as a rule that says no.
  • roles are the roles an invite may give; the default is defaultRole alone. An invite without role gives defaultRole; any role not listed answers 403 role_not_allowed.

With someone signed in through db.auth, a SolidJS component that invites and lists the open invites:

tsx
// db.ts: export const db = dabadata.createClient({ url: "https://dabadata.com", key: "pk_test_..." });
import { createResource, createSignal, For, Show } from "solid-js";
import { db } from "./db";

export function Invites() {
  const [invites, { refetch }] = createResource(() => db.auth.invites());
  const [email, setEmail] = createSignal("");
  const [error, setError] = createSignal("");

  async function send(e: SubmitEvent) {
    e.preventDefault();
    setError("");
    try {
      await db.auth.invite({
        email: email(),
        role: "viewer",
        redirectUrl: location.origin + "/?mode=app", // the link comes back to this app
      });
      setEmail("");
      refetch();
    } catch (err: any) {
      setError(err.message); // invite_not_allowed, role_not_allowed, rate_limited, validation
    }
  }

  return (
    <section>
      <form onSubmit={send}>
        <input type="email" required placeholder="Email" value={email()} onInput={(e) => setEmail(e.currentTarget.value)} />
        <button>Invite</button>
      </form>
      <Show when={error()}><p role="alert">{error()}</p></Show>
      <ul>
        <For each={invites()}>
          {(i) => (
            <li>
              {i.email} ({i.role}), open until {new Date(i.expiresAt).toLocaleDateString()}
              <button onClick={() => db.auth.cancelInvite(i.id).then(refetch)}>Cancel</button>
            </li>
          )}
        </For>
      </ul>
    </section>
  );
}

What the invited address gets:

  • A new address: an account with the role and name you sent, no password and the address not yet confirmed, and a 7-day link by mail - "Dana invited you to Shop", with the inviter's name (or email). The link opens the hosted invite page, or with ?mode=app your own page with #token=...&type=invite. Choosing a password confirms the address, signs the person in and sends them to redirectUrl.
  • An address invited before that never signed in: the old link stops working and a new one is mailed. The account itself (role, name) stays as it was.
  • An address with an account: nothing changes. The mail says they already have an account and links to the sign-in page, never with a token. A disabled account gets no mail.

The answer is { ok: true } every time, so inviting does not tell anyone whether an address has an account. (The inviter's own list can: an address that already had one never shows up in it.) invites() holds the signed-in user's open invites - not used, not expired, the person has not signed in - newest first, and cancelInvite(id) drops the link and removes the account if the person never got in. Each user sees and cancels only their own.

On the page the link opens in app mode, finish it with a password:

ts
// The page the invite link opens in app mode: #token=...&type=invite
const type = new URLSearchParams(location.hash.slice(1)).get("type");
if (type === "invite" || type === "reset") {
  // Show a "choose a password" form, then:
  await db.auth.completeLink({ password }); // address confirmed, signed in
} else {
  await db.auth.completeLink();
}

Limits per day: 20 invites per inviter, 3 per invited address and 200 per environment.

App admins

An admin page in your app can list the users, change their roles and names, and disable or remove them, with nothing but the admin's own sign-in. Say who may, with a rule:

json
"auth": {
  "users": {
    "enabled": true,
    "defaultRole": "member",
    "manage": { "role": "admin" },
    "manageRoles": ["member", "moderator"]
  }
}
  • manage is a rule like a table's ("authenticated", { "role": [...] }, any, all), checked against the caller's current role. owner has no row to look at here, so it is an error.
  • manageRoles (default: every declared role) are the roles a manager may give, and the roles of the users they may change, disable or remove. Above, an admin manages members and moderators but cannot touch another admin, nor make anyone an admin.
  • A manager cannot change their own role, nor disable or remove themselves (403 self_not_allowed); their own name they may change.
  • Everything else is as the secret-key routes of Managing users: disabling signs the user out everywhere, a new role or name reaches their next call, and every change is in the workspace's audit log with the admin as the actor.
ts
// On the admin page, signed in as an admin.
const { users, next } = await db.auth.admin.listUsers({ query: "example.com" });
await db.auth.admin.setRole(users[0].id, "moderator");
await db.auth.admin.disable(users[1].id);   // signed out everywhere
await db.auth.admin.enable(users[1].id);
await db.auth.admin.remove(users[2].id);

The list holds each user's email, role, disabled, lastSignInAt, hasPassword and providers. Errors: 403 manage_not_allowed (no manage, or the rule refuses the caller), 403 role_not_allowed, 403 self_not_allowed, 404 for an unknown user.

Profiles: the users table

A comment, an attendee list or a "created by" needs other people's names, but an account is private. profiles gives your rules a table to read instead:

json
"users": {
  "enabled": true,
  "defaultRole": "member",
  "profiles": { "read": "authenticated", "fields": ["name", "role"] }
}
  • The environment gets a read-only table users: one row per user, id (the user's id, the same value userId columns hold), name, created_at, and role only when fields lists it (default ["name"]). Never the email.
  • read is the table's read rule (a rule on the caller; owner is an error). Nobody writes it, not even the secret key: the server keeps it, as users sign up, are invited, renamed, given a role or deleted. Disabled and invited-but-not-yet-joined users have a row too.
  • Read it like any table: db.query("users"), GET /v1/db/users, db.subscribe("users") for names that change live. A rename reaches every subscriber.
  • With profiles on, no declared table may be called users. Turning it on fills the table from the existing users. A users table already in the database that dabadata did not make (an old declared table) stops the apply until you drop it; turning profiles off leaves the table behind as drift.
ts
// Show the author's name next to each comment.
const names = new Map((await db.query("users")).map((u) => [u.id, u.name]));
for (const c of await db.query("comments")) console.log(names.get(c.author) ?? "someone", c.body);

A row for every new user (onSignUp)

Many apps keep a row per member (a points balance, settings, a profile with more than a name). onSignUp makes dabadata insert it, with values the client cannot fake:

json
"onSignUp": {
  "table": "members",
  "values": { "user": "$user.id", "email": "$user.email", "name": "$user.name",
              "joined": "$now", "points": 0 }
}
  • It runs whenever a user is created: sign-up, a sign-in link that creates the account, an invite from the app, and a user you add (console or POST /v1/project/users).
  • values are literals or one of $user.id (a userId or text column), $user.email (text), $user.name (text; the column must be optional, a user may have no name) and $now (datetime or date). Every required column without a default must be given; the definition is refused otherwise.
  • The row is written as the server: the table's rules are not asked, so create can stay "nobody". It goes in the same transaction as the account: if the row fails (a unique or check constraint), no account is made and the call answers 409 on_signup_failed.
  • The values are the ones at creation; a later name change is not copied (use profiles for names that follow). The insert is in the trace with values._onSignUp = the new user's id.

Managing users

In the console, Users lists the environment's accounts (viewers can read it; developers and up change it): search by email or name, add someone, change a role, disable, delete. With the secret key, the same under /v1/project/users:

bash
# list, newest first; ?query= searches email and name, ?after= pages
curl "https://dabadata.com/v1/project/users?query=example.com&limit=50" \
  -H "Authorization: Bearer $DABADATA_SECRET_KEY"

# add someone and mail them a 7-day link to choose a password
curl -X POST https://dabadata.com/v1/project/users \
  -H "Authorization: Bearer $DABADATA_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","name":"Someone","role":"viewer","invite":true}'

# change a role, or disable (signs them out everywhere)
curl -X PATCH https://dabadata.com/v1/project/users/usr_k3x9m2p7q4w8z1ab \
  -H "Authorization: Bearer $DABADATA_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"disabled":true}'
  • Each user in the list (and the answers of PATCH) carries disabled, lastSignInAt, hasPassword and providers: the sign-in providers linked to the user, such as ["google", "github"], in the order of the provider table.
  • Users you add count as confirmed: the project vouches for the address. Give a password, or invite: true to mail a link to the hosted invite page. This is how people join when signup is "invite". With invite: true, redirectUrl (one of yours; ?mode=app sends the link straight to your page) is where the person lands once their password is set.
  • Disabling revokes every token of the user and drops their unused links; they cannot sign in until you enable them again. Deleting removes the account and its tokens; rows they own stay in your tables.
  • A new role or name reaches the user's next call and next refresh; a socket already open keeps the identity it opened with.
  • Changes made in the console are in the workspace's audit log.

Resending an invitation

Someone who was invited but never chose a password shows as invited in the console's Users. If their mail got lost or the 7-day link expired, press Resend invite (or POST /v1/project/users/:id/invite with the secret key): a new link goes to the same account, so whatever your app linked to it stays, and the old link stops working. They can also type their address on your sign-in screen and use Forgot password, which works for an account with no password yet.

Copy users to live

When an app built on test goes live, the people who used it there can come along. In the console, on the test environment's Users, tick the users (or none, for everyone) and press Copy to live. Each gets a live account with the same email, name and role, and live mails them an invitation to choose a password.

  • Passwords are never copied, nor Google or other linked sign-ins: everyone chooses a new password from the mail.
  • The test accounts stay, and so do the rows they own in test; nothing else moves to live.
  • Skipped, with the reason shown: disabled users, addresses live already has, and roles the live definition does not declare. Apply your definition to live first.
  • Console only (developers and up), at most 200 users at a time: POST /v1/project/users/to-live.

The test inbox

In a test environment, every mail your users would get - the confirmation, sign-in links, password resets and invitations - is also kept in the environment's test inbox for 7 days (the newest 200). Open it in the console under Authentication - Test inbox: each message with its link, whether the link is unused, used or expired, and buttons to open or copy it.

  • Stop sending test mail. Turn off "Also send test mail to the real inboxes" (or set "testMail": "inbox" in auth.users): test sign-ups then need no real address, and nothing leaves dabadata. Live environments ignore the setting; their mail always goes out and is never kept.
  • Try the failure answers. "Make it expired" and "Mark it used" change a mailed link on the spot, so your app's handling of 400 link_invalid can be tested without waiting 24 hours. "Copy a broken link" gives the same link with one character of its token changed.

A script or a test suite can read it too, with the test environment's secret key:

bash
curl "https://dabadata.com/v1/project/[email protected]&limit=1" \
  -H "Authorization: Bearer $DABADATA_TEST_SECRET_KEY"
# -> { "messages": [ { "id": "tm_...", "to": "[email protected]", "subject": "Confirm your email for Pulse",
#      "purpose": "verify", "link": "https://dabadata.com/auth/prj_..._test/verify?token=...",
#      "linkState": { "state": "unused", "expiresAt": "...", "usedAt": null }, "createdAt": "..." } ],
#      "sending": false }

curl -X POST https://dabadata.com/v1/project/test-mail/tm_.../link \
  -H "Authorization: Bearer $DABADATA_TEST_SECRET_KEY" -H "Content-Type: application/json" \
  -d '{"state":"expired"}'

# After a test run: delete its messages only (or one: DELETE .../test-mail/tm_...)
curl -X DELETE "https://dabadata.com/v1/project/[email protected]" \
  -H "Authorization: Bearer $DABADATA_TEST_SECRET_KEY"

The messages hold working sign-in links, so only the secret key and console members with the developer role or above can read them. Details: HTTP API: test inbox.

Rate limits and link lifetimes

RouteLimit (per environment)
signup20 per IP address per hour
magic20 per IP address per hour
signup and magic together5 per email address per hour
signin (and the current-password check of PATCH me)10 per email address and 30 per IP address per 15 minutes, every attempt counted
forgot5 per email address per hour
invite20 per inviter, 3 per invited address and 200 in all, per day

Over a limit: 429 { "error": "rate_limited", "retryAfter" } with Retry-After. The routes also count toward the publishable key's request rate. Links work once: a confirmation link for 24 hours, a sign-in link for 15 minutes, a reset link for an hour and an invitation for 7 days.

Errors

StatuserrorWhen
400validationA malformed address, a password that breaks the rules, a redirectUrl that is not listed. Every problem in messages.
400link_invalidThe link's token is unknown, used or expired.
401invalid_loginWrong email or password (the same answer for an address with no account).
401unauthorizedme, signout, an invite or admin/ route without a live token of a hosted user of this environment; a wrong publishable key.
403email_unverifiedThe right password, but the address is not confirmed yet.
403user_disabledThe account is disabled.
403signup_closedSign-up is "invite".
403invalid_loginPATCH me: the current password is wrong.
403invite_not_allowedThe project has no auth.users.invite, or its rule refuses this user.
403role_not_allowedAn invite asked for a role outside auth.users.invite.roles; an app admin gave a role, or touched a user whose role is, outside auth.users.manageRoles.
403manage_not_allowedThe project has no auth.users.manage, or its rule refuses this user.
403self_not_allowedAn app admin tried to change their own role, or disable or remove themselves.
409on_signup_failedThe onSignUp row could not be inserted; the account was not created.
404not foundHosted accounts are not enabled in this environment; DELETE invites/:id for an invite that is not one of yours, or no longer open.
429rate_limitedSee above.

With identity providers

Hosted accounts are one more way in, next to the others: a provider's JWT, a token your backend mints, and anonymous. To dabadata a hosted user is simply a user whose issuer is hosted.

  • Pick hosted accounts when your app has no sign-in yet. Keep your provider (Google, Clerk, Auth0, Firebase, Supabase, your own) when it already has one: declare it under auth.issuers and send its tokens as they are.
  • Social sign-in or a provider's tokens? With providers, people sign in with Google or GitHub and become hosted users that dabadata keeps. With auth.issuers, the provider keeps the accounts and dabadata only checks its tokens.
  • Both at once is fine: auth: { users, issuers }. For example, staff sign in with your company's Google and customers with hosted accounts. Rules see a role either way; a rule never looks at the issuer.
  • The issuer name hosted is reserved for hosted users, so no entry of auth.issuers may use it. One older name is reserved too (see the note on the product's earlier name).
  • A hosted user's role is stored with the account (set by defaultRole, changed by you); a provider user's role comes from a claim in their token.
dabadata documentation, written against API contract v2.