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:
- Answer from projections, not documents.
getInforeturns three access-control fields; fetching the full document body to check ownership adds unnecessary load on every operation. - Keep token verification cheap.
jwt.verifyagainst 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
falsefrombeforeConnectrejects the socket handshake; the client's connection fails. - A
falsefrombeforeLoadreturns an authorization error to the requesting client only. - A
falsefrombeforeChangerejects 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
getInfoprojection contract your hooks rely on.