Github|...

Authentication

Sign users in with SurrealDB access methods, and scope every live query with row-level permissions.

Authentication in Sp00ky is built directly into the sql schema, making it robust, typesafe, and dynamic. Because access control is defined at the database layer, your rules are consistently enforced regardless of how the data is accessed.

Schema Configuration

You define authentication using DEFINE ACCESS. This allows you to specify exactly how users sign up and sign in, and what permissions they have.

-- Define access scope for accounts
DEFINE ACCESS account ON DATABASE TYPE RECORD
  SIGNUP ( 
    CREATE user 
    SET username = $username,
      password = crypto::argon2::generate($password)
  )
  SIGNIN (
    SELECT * FROM user 
    WHERE username = $username 
      AND crypto::argon2::compare(password, $password)
  )
  DURATION FOR TOKEN 15m, FOR SESSION 30d;

-- Permissions use the $auth variable to check the current user
DEFINE TABLE user SCHEMAFULL
PERMISSIONS
FOR update, delete WHERE id = $auth.id
FOR select, create WHERE true;

Client-Side Authentication

Sp00ky provides a strongly-typed authentication service on the client. It integrates seamlessly with the schema definitions.

Sign In

import { db } from './db';

// The second argument is typesafe based on your SIGNIN definition
await db.auth.signIn('account', {
username: 'sp00ky_user',
password: 'secure_password'
});

Sign Up

import { db } from './db';

await db.auth.signUp('account', {
username: 'new_user',
password: 'secure_password'
});

Sign Out

await db.auth.signOut();

Reacting to Auth State

You can subscribe to authentication state changes to update your UI dynamically. The callback receives the user ID when authenticated, or null when signed out.

import { createSignal, onCleanup } from 'solid-js';

function MyComponent() {
  const [userId, setUserId] = createSignal<string | null>(null);
  
  // Subscribe to auth state changes
  const unsubscribe = db.auth.subscribe((uid) => {
    setUserId(uid);
    if (uid) {
      console.log('User logged in:', uid);
    } else {
      console.log('User logged out');
    }
  });
  
  // Clean up subscription
  onCleanup(() => unsubscribe());
  
  return <div>User ID: {userId() || 'Not logged in'}</div>;
}

Local Data Isolation

Every auth change also switches the client’s local storage bucket. Each user has their own IndexedDB store on the device (signed-out sessions use a shared anonymous bucket), so:

  • After sign-out or an account switch, the previous user’s cached rows are no longer readable, live queries emit an empty result immediately and refill from the server for the new session.
  • Signing out does not delete the user’s bucket: their cache stays warm and any un-pushed offline mutations remain queued in their bucket, resuming automatically the next time they sign in on that device.
  • Mutations made while signed out stay in the anonymous bucket; they are not replayed under a signed-in user’s identity.

Nothing to configure. This is the default behavior with database.store: 'indexeddb'. Details in Architecture → Per-User Local Buckets.

Row-Level Permissions in Live Queries

A table’s PERMISSIONS FOR select clause is more than a server-side gate: Sp00ky compiles it into a row filter that is injected into every live query on that table, enforced identically by the server SSP and the in-browser WASM processor. A client never registers a query that could return rows its permission forbids, and the filter is re-evaluated incrementally as data changes.

Supported expressions

  • Field comparisons: =, !=, <, <=, >, >=, and string prefix matches.
  • Boolean composition with AND and OR.
  • The $auth and $access parameters (e.g. owner = $auth.id, $access = "account").
  • Relational membership via IN (SELECT VALUE <field> FROM <table> WHERE …). The inner WHERE may itself reference $auth. This lowers to an incremental semi-join, so when the referenced table changes (a broadcast is made public, a share is revoked) the affected live queries update on their own. No refetch.
  • A subquery that projects a record-link field (SELECT VALUE link.field) resolves the link with a nested join, so two-hop rules like “rows whose owner is the owner of a broadcast shared with me” work.
-- A live stream's presence row is readable by its owner, by anyone when the
-- broadcast is public, and by collaborators the owner shared it with as admin.
DEFINE TABLE stream_presence SCHEMAFULL
  PERMISSIONS
    FOR select WHERE
      -- 1. the owner reads their own rows (flat comparison)
      ( $access = "account" AND owner = $auth.id )
      -- 2. anyone reads a publicly shared broadcast's rows (relational subquery)
      OR owner IN (SELECT VALUE owner FROM broadcast WHERE share_visibility = 'public')
      -- 3. an admin collaborator reads them (subquery over a record link)
      OR ( $access = "account" AND owner IN (
             SELECT VALUE broadcast.owner FROM broadcast_share
             WHERE user = $auth.id AND role = 'admin') )
    FOR create, update, delete WHERE false;
Note

All three branches above are enforced on the client too. A viewer only ever syncs the presence rows they are actually allowed to see; making a broadcast private retracts its rows from every unrelated viewer’s live query.

Not supported

Unsupported permission constructs

EXISTS (SELECT …) and $parent correlation inside a permission clause are not representable in the live-query engine and are rejected at registration time with the offending table named. Rewrite EXISTS as an IN (SELECT VALUE …) membership check. A table whose permission fails to compile is never synced (fail-closed), so a client can never receive rows an unrepresentable rule was meant to guard.

Ending Sessions (Session Guard)

SurrealDB checks a token only when a connection authenticates. A WebSocket that signed in before a password reset keeps its identity for as long as it stays open, even though your AUTHENTICATE clause would refuse that token on the next connection. The session guard is how you end such a session for sync: a SurrealQL expression over $auth (re-read on every check) and $token (the claims the session signed in with).

# sp00ky.yml
sessionGuard: "$auth.session_epoch = NONE OR ($token.iat ?? 0) >= $auth.session_epoch"

spky migrate makes it the body of fn::sp00ky::session_live(), and every sp00ky-owned rule that admits a signed-in user asks it: the per-user edge tables, _00_query and fn::query::register. SurrealDB evaluates those rules again for every live notification, so a session the guard turns down receives nothing from the next change on, without reconnecting. Its next registration is refused with “There was a problem with authentication”. The web client answers that, and every reconnect, by checking the token again, which signs the device out once your AUTHENTICATE clause refuses it.

The guard runs like a permission clause, with permissions off, so it may read a field the user cannot. Call it from your own rules as well, so an open socket cannot read or write your tables directly either. Live queries are not affected: the SSP reads the call as true, because what it builds reaches a client only through the guarded edge tables.

-- Stamped by your backend (as root) when the password is reset.
DEFINE FIELD session_epoch ON TABLE user TYPE option<int>
  PERMISSIONS FOR select WHERE false, FOR create, update WHERE false;

-- Refuse old tokens on every new connection...
DEFINE ACCESS OVERWRITE account ON DATABASE TYPE RECORD
  -- SIGNUP / SIGNIN / WITH JWT as before
  AUTHENTICATE {
    IF !fn::sp00ky::session_live() {
      THROW "There was a problem with authentication: this session has ended, sign in again";
    };
    RETURN $auth;
  };

-- ...and on your own tables, for sockets that are already open.
DEFINE TABLE OVERWRITE note SCHEMAFULL PERMISSIONS
  FOR select, create, update, delete
    WHERE owner = $auth.id AND fn::sp00ky::session_live();
Note

Unset, every session stays live, exactly as before. Anything but true refuses, so a guard that reads a missing field ends every session: keep the = NONE branch for accounts that never reset.

Third-Party Providers

Clerk

Note Integration coming soon.

Firebase

Note Integration coming soon.

Auth0

Note Integration coming soon.