Authentication
The model
Jspreadsheet Server has no login system, user tables or password storage; it is identity-agnostic. Every request passes through three hooks you implement, each receiving the client's auth object verbatim, whatever it holds (a JWT from Cognito, Auth0 or your own issuer, a session id, an API key). The hooks decide whether the request proceeds.
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, so they can verify tokens and query your database. They gate both the WebSocket channel and the REST API, so access decisions live in one place.
The auth object
Auth over the WebSocket
Everything in the auth option of the client extension travels with every interaction: 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), exposed to the hooks the same way: auth.token and auth.invitation. The shape of auth is yours; the server never interprets it.
Minimal configuration
JWT verification on all three hooks
A minimal setup verifies a token in every hook. This example uses a JWT with the jsonwebtoken library; any other token scheme works the same way:
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. 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
beforeChange runs on every operation, so:
- Answer from projections, not documents.
getInforeturns three access-control fields; fetching the full document body to check ownership adds load to 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, since it only needs three fields: the owner, the privacy flag and the invited users. 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 that projects only the access-control fields, leaving the document body (spreadsheet) in the database. The official adapters ship this as adapter.getInfo(guid); you only write it when building your own adapter. The MongoDB equivalent 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
falsefrombeforeLoadis answered like a missing document (not found), 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.