← All docs
Raw

Auth

Every Unbase project ships a built-in, Supabase-style Auth service for authenticating the end users of your app — the people who sign into the product you build on Unbase, not you (the project owner). It hands those users JWT sessions you can verify in your own backend.

All endpoints live under:

AUTH = https://api.unbase.dev/v1/projects/:id/auth

Keys and headers

Your project has two keys (both returned by POST /v1/projects):

  • Anon key${projectId}.pk.${signature}. Safe to embed in a client app. Sent in an apikey header, it unlocks the Auth endpoints only — it cannot read or write your SQL tables.
  • Secret key${projectId}.${signature}. Full data-plane + admin access. Also accepted in the apikey header, and required for the two admin endpoints. Never expose it in a browser.

Every Auth call authenticates with the anon key in an apikey header (the secret key works too):

apikey: unbase_abc123.pk.f0e1d2...

You only store the project URL + secret key. The AUTH base is just ${UNBASE_PROJECT_URL}/auth, and the anon key + JWT secret come back from GET {AUTH}/settings (secret key only, see below) — so fetch them once server-side instead of pinning them as extra env vars.

Endpoints that act on a signed-in user (/password, /user) additionally take that user's access token as a bearer token:

apikey: unbase_abc123.pk.f0e1d2...
authorization: Bearer <accessToken>

The Session object

signup, signin, verify, token, password, and reset all return a Session:

{
  "accessToken": "<HS256 JWT>",
  "tokenType": "bearer",
  "expiresIn": 3600,
  "expiresAt": 1751824800,
  "refreshToken": "<single-use>",
  "user": {
    "id": "user_...",
    "email": "u@x.com",
    "emailConfirmedAt": null,
    "createdAt": 1751821200000,
    "lastSignInAt": 1751821200000
  }
}

The accessToken is an HS256 JWT signed with the project's own JWT secret (retrievable via GET {AUTH}/settings), so you can verify end-user tokens in your own backend with any standard JWT library. Claims:

{ "sub": "user_...", "iss": "<projectId>", "role": "authenticated", "email": "u@x.com", "iat": 1751821200, "exp": 1751824800 }
  • accessToken is short-lived (1 hour). Refresh it with POST {AUTH}/token.
  • refreshToken is long-lived (30 days) and single-use — each exchange rotates it, so always store the new one.

Endpoints

Method & path Auth Body Returns
POST {AUTH}/signup apikey { email, password } 201, Session
POST {AUTH}/signin apikey { email, password } 200, Session
POST {AUTH}/magiclink apikey { email, redirectTo? } { sent, devLink? }
POST {AUTH}/verify apikey { token } 200, Session
POST {AUTH}/token apikey { refreshToken } 200, Session
POST {AUTH}/password apikey + bearer { currentPassword?, newPassword } 200, Session
POST {AUTH}/recover apikey { email, redirectTo? } { sent, devLink? }
POST {AUTH}/reset apikey { token, newPassword } 200, Session
POST {AUTH}/user apikey + bearer {} { user }
POST {AUTH}/logout apikey { refreshToken } 204
GET {AUTH}/users secret key (admin) { users: [...] }
GET {AUTH}/settings secret key (admin) { jwtSecret, userCount, anonKey, authUrl }

Passwords must be at least 8 characters. Email addresses are normalized (trimmed and lower-cased).

Email + password

Sign a user up and receive a Session in one call:

curl -s -X POST "$AUTH/signup" \
  -H "apikey: $ANON_KEY" -H "content-type: application/json" \
  -d '{"email":"user@example.com","password":"hunter2!!"}'

signin is identical but returns 200 (and 401 on a wrong password). A duplicate signup returns 409.

Login and register forms

Because the anon key is safe to embed in a client and only reaches the Auth endpoints, you can talk to signup/signin straight from the browser. The pattern is always the same: POST the credentials, get a Session back, persist the tokens, and hang on to the refreshToken so you can keep the user signed in.

A tiny client

Wrap the two calls once so the forms stay declarative. This works in any framework (or none) — it's just fetch.

// unbase-auth.js
const AUTH = `${import.meta.env.VITE_UNBASE_PROJECT_URL}/auth`;
const ANON_KEY = import.meta.env.VITE_UNBASE_ANON_KEY;

async function call(path, body) {
  const res = await fetch(`${AUTH}${path}`, {
    method: "POST",
    headers: { apikey: ANON_KEY, "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(data.error?.message ?? "Auth request failed");
  return data; // a Session
}

export const register = (email, password) => call("/signup", { email, password });
export const login = (email, password) => call("/signin", { email, password });

// Keep the session across reloads. Store the tokens wherever you keep app
// state — localStorage is fine for the refresh token; the short-lived
// accessToken can live in memory.
export function saveSession(session) {
  localStorage.setItem("unbase:session", JSON.stringify(session));
}
export function loadSession() {
  const raw = localStorage.getItem("unbase:session");
  return raw ? JSON.parse(raw) : null;
}
export function clearSession() {
  localStorage.removeItem("unbase:session");
}

The anon key and project URL are publishable, so shipping them to the browser is expected. Never put the secret key in client code — it has full read/write access to your data.

Register form (React)

signup returns 201 with a Session, so a new user is signed in the moment they register — no second round-trip. A duplicate email comes back as 409, which surfaces here as an error message.

import { useState } from "react";
import { register, saveSession } from "./unbase-auth";

export function RegisterForm({ onAuthed }) {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [state, setState] = useState("idle"); // idle | loading | error
  const [error, setError] = useState(null);

  async function submit(e) {
    e.preventDefault();
    setState("loading");
    setError(null);
    try {
      const session = await register(email.trim(), password);
      saveSession(session);
      onAuthed?.(session);
    } catch (err) {
      setState("error");
      setError(err.message); // e.g. "email already registered"
    }
  }

  return (
    <form onSubmit={submit}>
      <input
        type="email"
        required
        placeholder="you@example.com"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <input
        type="password"
        required
        minLength={8}
        placeholder="At least 8 characters"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit" disabled={state === "loading"}>
        {state === "loading" ? "Creating account…" : "Create account"}
      </button>
      {error && <p role="alert">{error}</p>}
    </form>
  );
}

Login form (React)

Identical shape — swap register for login. signin returns 200 on success and 401 on a wrong password, which lands in the same catch.

import { useState } from "react";
import { login, saveSession } from "./unbase-auth";

export function LoginForm({ onAuthed }) {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [state, setState] = useState("idle");
  const [error, setError] = useState(null);

  async function submit(e) {
    e.preventDefault();
    setState("loading");
    setError(null);
    try {
      const session = await login(email.trim(), password);
      saveSession(session);
      onAuthed?.(session);
    } catch (err) {
      setState("error");
      setError("Invalid email or password");
    }
  }

  return (
    <form onSubmit={submit}>
      <input
        type="email"
        required
        placeholder="you@example.com"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <input
        type="password"
        required
        placeholder="Password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit" disabled={state === "loading"}>
        {state === "loading" ? "Signing in…" : "Sign in"}
      </button>
      {error && <p role="alert">{error}</p>}
    </form>
  );
}

No framework? Plain HTML + JS

The same flow without a build step — the <form>'s submit event does the work:

<form id="login">
  <input name="email" type="email" required placeholder="you@example.com" />
  <input name="password" type="password" required placeholder="Password" />
  <button type="submit">Sign in</button>
  <p id="err" role="alert"></p>
</form>

<script type="module">
  import { login, saveSession } from "./unbase-auth.js";

  document.getElementById("login").addEventListener("submit", async (e) => {
    e.preventDefault();
    const { email, password } = Object.fromEntries(new FormData(e.target));
    try {
      const session = await login(email.trim(), password);
      saveSession(session);
      location.href = "/dashboard";
    } catch (err) {
      document.getElementById("err").textContent = err.message;
    }
  });
</script>

After sign-in

Once you hold a Session, send the accessToken as a bearer token on requests to your own backend, which verifies the JWT with the project's JWT secret (see the Session object). When the access token expires (401), swap the stored refreshToken for a fresh Session via POST {AUTH}/token — remembering that the refresh token is single-use, so save the new one each time.

Passwordless (magic link)

  1. Request a link. Unbase emails the user a URL pointing at redirectTo?token=... (falling back to <SITE_URL>/auth/callback?token=...). In keyless dev mode nothing is emailed and the link comes back inline as devLink.

    curl -s -X POST "$AUTH/magiclink" \
      -H "apikey: $ANON_KEY" -H "content-type: application/json" \
      -d '{"email":"user@example.com","redirectTo":"https://myapp.example/welcome"}'
    
  2. Your app pulls the token off the redirect URL and exchanges it for a Session. The user is created on first verify.

    curl -s -X POST "$AUTH/verify" \
      -H "apikey: $ANON_KEY" -H "content-type: application/json" \
      -d '{"token":"<token-from-link>"}'
    

Refreshing and signing out

Exchange a refresh token for a fresh Session (the old refresh token is immediately invalidated):

curl -s -X POST "$AUTH/token" \
  -H "apikey: $ANON_KEY" -H "content-type: application/json" \
  -d '{"refreshToken":"<refreshToken>"}'

Revoke a refresh token to sign the user out:

curl -s -X POST "$AUTH/logout" \
  -H "apikey: $ANON_KEY" -H "content-type: application/json" \
  -d '{"refreshToken":"<refreshToken>"}'

Changing a password (signed in)

A signed-in user changes their own password by presenting their access token as a bearer token alongside the apikey:

curl -s -X POST "$AUTH/password" \
  -H "apikey: $ANON_KEY" \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  -d '{"currentPassword":"hunter2!!","newPassword":"a-stronger-passphrase"}'
  • A user who already has a password must supply the correct currentPassword (401 if it's wrong).
  • A passwordless (magic-link-only) user omits currentPassword to set a password for the first time.
  • The new password must be at least 8 characters.
  • Changing the password revokes the user's other sessions and returns a fresh Session, so the caller stays signed in while any other devices are logged out.

Resetting a forgotten password

A two-step flow, mirroring magic link.

  1. Request a reset link. Unbase mints a stateless, 1-hour recovery token and emails a link pointing at redirectTo?token=... (falling back to <SITE_URL>/auth/reset?token=...). This endpoint always reports success, never revealing whether an account exists. Keyless dev mode returns the link inline as devLink.

    curl -s -X POST "$AUTH/recover" \
      -H "apikey: $ANON_KEY" -H "content-type: application/json" \
      -d '{"email":"user@example.com","redirectTo":"https://myapp.example/reset"}'
    
  2. Set the new password. Your app pulls the token off the redirect URL and posts it with the new password. This sets the password, revokes all prior sessions, and returns a fresh Session so the user is signed straight in.

    curl -s -X POST "$AUTH/reset" \
      -H "apikey: $ANON_KEY" -H "content-type: application/json" \
      -d '{"token":"<token-from-link>","newPassword":"a-brand-new-passphrase"}'
    

The current user

Resolve an access token to its user (verifies the JWT signature and expiry):

curl -s -X POST "$AUTH/user" \
  -H "apikey: $ANON_KEY" \
  -H "authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" -d '{}'

Returns { "user": { ... } }, or 401 if the token is missing, invalid, or expired.

Admin endpoints (secret key only)

These require the secret key as Authorization: Bearer <secretKey> — the anon key is rejected.

  • GET {AUTH}/users{ users: [...] } — every end user registered with the project (most recent first).
  • GET {AUTH}/settings{ jwtSecret, userCount, anonKey, authUrl } — the project's Auth configuration, including the JWT secret you need to verify end-user access tokens in your own backend.

Errors

All errors are JSON: { "error": { "code": "...", "message": "..." } }.

HTTP code When
400 bad_request Missing/short password, missing or malformed/expired token, invalid email
401 unauthorized Missing/invalid apikey, wrong currentPassword, invalid or expired access/refresh token, token for a different project
409 conflict signup where the email already exists

See also

  • openapi.yaml — full machine-readable schemas for every Auth endpoint (tags: [auth]).
  • llms.txt — dense, AI-agent-friendly API reference.
  • quickstart.md — five-minute intro covering project creation and running SQL.