Authentication

The model

Jspreadsheet Server does not ship a login system, user tables or password storage. It is identity-agnostic. Every request passes through three hooks you implement, and each hook receives the auth object the client supplied. Whatever your application uses for identity, whether a JWT from Cognito, Auth0 or your own issuer, a session id or an API key, the hooks receive it verbatim and decide whether the request may proceed.

The three hooks

Hook Signature Fires
beforeConnect (auth) => boolean When a client connects, and on every REST request
beforeLoad (guid, auth) => boolean Before a document is loaded or read
beforeChange (guid, changes, auth) => boolean Before each operation is applied to a document

Allow, deny and async decisions

Return true to allow, false to deny. All three may be async, and verifying tokens and querying your database inside them is the normal pattern. They gate both the WebSocket channel and the REST API, so access decisions are made in one place.

The auth object

Auth over the WebSocket

On the client, everything you put in the auth option of the client extension travels with every interaction, including the WebSocket handshake and every subsequent event:

const remote = client.connect({
    url: 'https://sheets.example.com',
    auth: {
        token: token,            // arrives as auth.token in every hook
        invitation: invitation,  // optional sharing invitation code
    },
});

Auth over the REST API

On the REST API the token is a Bearer header (Authorization: Bearer <token>, with an optional ,<invitation> suffix), and the server exposes it to the hooks the same way: auth.token and auth.invitation. The shape of auth is yours, and the server never interprets it.

Minimal configuration

JWT verification on all three hooks

A minimal setup verifies a token in every hook. The example below uses a JWT with the jsonwebtoken library; any token scheme your application already uses works the same way, because the hooks receive the token and your code validates it:

const server = require('@jspreadsheet/server');
const jwt = require('jsonwebtoken');

const verify = (auth) => {
    try {
        // Throws on a bad signature or an expired token
        return jwt.verify(auth.token, process.env.JWT_SECRET);
    } catch (e) {
        return null;
    }
};

server({
    port: 3000,
    beforeConnect: async function(auth) {
        // No valid token, no connection
        return !!verify(auth);
    },
    beforeLoad: async function(guid, auth) {
        // May this user see this document?
        return !!verify(auth);
    },
    beforeChange: async function(guid, changes, auth) {
        // May this user modify it?
        return !!verify(auth);
    },
    // ... persistence handlers, see /docs/server/adapters
});

Verify token signatures

Use jwt.verify, not jwt.decode: decode reads the payload without checking the signature, so its claims cannot be trusted. Verification requirements and key-caching guidance are on the security page.

Access levels

Most applications need more than allow/deny: an owner who can do everything, editors who can change data, viewers who can only read. The recommended structure is one function that resolves the user's level for a document, with every hook deriving its answer from it:

The three levels

  • Owner (2): full access, including configuration, sharing and snapshots
  • Editor (1): can change the spreadsheet
  • Viewer (0): read-only

Example: level-based hooks

/**
 * Resolve the user's access level for a document.
 * @param {string} guid The document's unique identifier
 * @param {object} auth The client's auth object
 * @returns {number|false} 2 owner, 1 editor, 0 viewer, false: no access
 */
const getUserLevel = async function(guid, auth) {
    const info = verify(auth);
    // Lightweight access-control projection: owner, privacy flag, invited
    // users, never the document body. See /docs/server/adapters#the-adapter-surface
    const document = await adapter.getInfo(guid);
    if (document) {
        // The JWT subject matches the document owner
        if (info && info.sub === document.user_id) {
            return 2;
        }
        // Public documents grant editor access
        if (!document.spreadsheet.privacy) {
            return 1;
        }
        // Invitation codes carry their own level (see /docs/server/sharing)
        const invited = document.users?.find((u) => u.hash === auth.invitation);
        if (invited) {
            return invited.level;
        }
    }
    return false;
};

server({
    port: 3000,
    beforeConnect: async function(auth) {
        // Anyone with a valid token may connect
        return !!verify(auth);
    },
    beforeLoad: async function(guid, auth) {
        // Any level, viewers included, may load
        return await getUserLevel(guid, auth) !== false;
    },
    beforeChange: async function(guid, changes, auth) {
        // Only editors and owners may change; and ownership-sensitive
        // methods require the owner
        const level = await getUserLevel(guid, auth);
        const ownerOnly = ['setConfig', 'setUsers', 'deleteUsers',
            'createSnapshot', 'restoreSnapshot', 'deleteSnapshot'];
        if (ownerOnly.includes(changes.method)) {
            return level === 2;
        }
        return level >= 1;
    },
    // ... persistence handlers
});

Keeping the hooks fast

Because beforeChange runs on every operation, two practices keep it fast:

  1. Answer from projections, not documents. getInfo returns three access-control fields; fetching the full document body to check ownership adds unnecessary load on every operation.
  2. Keep token verification cheap. jwt.verify against a cached key is fast; fetching a JWKS per request is not. Cache keys, or cache the resolved level per (guid, token) for a few seconds if your levels change rarely.

Worked example: levels backed by PostgreSQL

Any storage can answer getUserLevel, because the logic above only needs three fields: the owner, the privacy flag, and the invited users. For example, this is the lookup against the PostgreSQL adapter's documents table:

The getInfo projection query

const getInfo = async function(guid) {
    const { rows } = await pool.query(
        `SELECT user_id,
                users,
                jsonb_build_object('privacy', spreadsheet->'privacy') AS spreadsheet
         FROM documents
         WHERE guid = $1`,
        [guid]
    );
    return rows[0];
};

One indexed primary-key read, projecting only the access-control fields, so the document body (spreadsheet) stays in the database. The official adapters ship this as adapter.getInfo(guid), so you only write this query yourself when building your own adapter. The equivalent MongoDB projection reads the same three fields from the documents collection.

Denying gracefully

Denial behavior by hook

  • A false from beforeConnect rejects the socket handshake; the client's connection fails.
  • A false from beforeLoad returns an authorization error to the requesting client only.
  • A false from beforeChange rejects that single operation; the client's local change is rolled back by the sync layer.

Logging and rate limiting denials

Log denials with the guid and the token subject, never the raw token, and rate-limit repeated failures at the proxy (Nginx).

What's Next?

  • Login & security: identity providers, JWT verification with JWKS, trust boundaries and the production checklist.
  • Sharing & privacy: where invitation codes and levels come from.
  • Adapters: the getInfo projection contract your hooks rely on.