Configuring Gated Identity
On this page 10 sections
Configuring Gated Identity
Gated Identity replaces session cookies and bearer tokens with a cryptographic proof of device ownership on every HTTP request. QSCSCore verifies the proof before the request reaches your application server, so unauthenticated requests are dropped at the edge, not inside your backend code.
This page covers the moving parts, where to get the browser-side library, how to inject it, and the minimal changes your application needs to understand and trust the resulting X-QSCS-Identity header.
Components overview
| Component | Where it runs | What it does |
|---|---|---|
qscs-crypto.js | Browser (JS) | Monkey-patches window.fetch; signs every same-origin request using the WASM substrate. |
| QSCS WASM substrate | Browser (WASM) | Generates and persists an ed25519 keypair in IndexedDB; exports qscs_sign_request. The private key never leaves WASM linear memory. |
| QSCSCore daemon | Server (edge node) | Verifies the ed25519 signature and replay protection on every request; injects X-QSCS-Identity: <uuid> for verified requests before forwarding to your origin. |
| Your application | Server (origin) | Reads X-QSCS-Identity; maps the UUID to a user account; skips cookie/token validation for already-gated requests. |
1. Getting qscs-crypto.js
The source is at github.com/SpookSystems/qscs-crypto. The repository contains the browser module and its companion WASM build.
# Clone
git clone https://github.com/SpookSystems/qscs-crypto.git
# Or download the built artefacts directly from the releases page:
# qscs-crypto.js — the fetch-shim
# qscs-substrate.wasm — the WASM module (also distributed with QSCSCore)Host both files on the same origin as your web application. They must be served over HTTPS in production.
2. How it works with the WASM substrate
On first visit:
- The WASM module loads and checks IndexedDB for an existing keypair.
- If none exists, it generates an ed25519 keypair, wraps the private key under a non-extractable AES-GCM key, and stores both in IndexedDB. The raw private key bytes are never accessible to JavaScript.
- The module fires a
qscs:readyDOM event. Your page (orqscs-crypto.js) waits for this event before making any authenticated request.
On every fetch() call to the same origin:
qscs-crypto.jsbuilds a canonical string:METHOD host path timestamp nonce.- It calls the WASM export
qscs_sign_request(canonical), which signs with the stored private key and returns a base64url signature. - Four headers are appended to the outgoing request:
X-QSCS-Client-Uuid(stable UUID derived from the public key),X-QSCS-Ts(Unix timestamp in seconds),X-QSCS-Nonce(random 16-byte hex; prevents replay), andX-QSCS-Sig(base64url ed25519 signature). - QSCSCore verifies the signature against the registered public key for that UUID. On success it appends
X-QSCS-Identity: <uuid>before forwarding the request. On failure it returns401 Unauthorizedimmediately.
3. Injecting qscs-crypto.js
Load the WASM substrate first, then the crypto shim, then your application bundle:
<!-- 1. WASM substrate loader (must come first) -->
<script src="/static/qscs-substrate.js"></script>
<!-- 2. Crypto shim (patches window.fetch after substrate fires qscs:ready) -->
<script src="/static/qscs-crypto.js"></script>
<!-- 3. Your application -->
<script src="/static/app.js"></script>With a bundler (Webpack, Vite, Rollup), import the shim at the top of your entry point before any module that calls fetch:
// entry.js
import "./lib/qscs-crypto.js"; // must be first
import "./app";Optional configuration (set on window before the script loads):
window.QSCS_CRYPTO_CONFIG = {
// Only sign requests to these path prefixes (default: all same-origin)
includePaths: ["/api/"],
// Never sign requests to these paths
excludePaths: ["/static/", "/public/"],
// Called when signing fails (default: let the request proceed unsigned)
onSignError: function (err) { console.warn("QSCS sign failed:", err); }
};4. Registering the public key at login
QSCSCore cannot verify a signature until it has the corresponding public key. Register it once during your existing login flow:
document.addEventListener("qscs:ready", async function () {
const pubKey = Module.qscs_get_public_key(); // base64url ed25519 public key
const uuid = Module.qscs_get_uuid(); // stable UUID for this device
await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: "...",
password: "...", // or OAuth token, code, etc.
qscs_uuid: uuid,
qscs_pubkey: pubKey
})
});
});Your login endpoint validates the credentials and returns {"authenticated": true}. QSCSCore stores the device public key locally after a successful response, your application does not need a key-storage table.
5. Application changes
5a. Custom authentication endpoint
By default, QSCSCore validates credentials against the spooksystems.org control plane. To gate your own application with your own user accounts, point QSCSCore at your verification endpoint using the IdentityAuthUrl key in /etc/qscs/qscs.conf:
[Identity Auth]
IdentityAuthUrl = https://your-app.com/api/qscs-authOn each POST /auth/login, QSCSCore calls your endpoint with:
POST https://your-app.com/api/qscs-auth
Content-Type: application/json
{
"uuid": "<device-uuid>",
"origin": "<request-host>",
"credentials": "username:password"
}Your endpoint validates the credentials against your user store and returns:
{ "authenticated": true } // success — device key registered by QSCSCore
{ "authenticated": false } // failure — 401 returned to browserOn success, QSCSCore registers the device public key locally and begins forwarding verified requests with X-QSCS-Identity: <uuid>. No key-storage table is required on your side.
5b. Origin flag
Add Auth=identity to each origin you want gated in /etc/qscs/qscs.conf:
[Origins]
example.com = 127.0.0.1:80 Auth=identityRequests without a valid QSCS identity now return 401 before reaching your application server. Verified requests receive X-QSCS-Identity: <uuid> from the daemon.
5c. OAuth 2.0 / JWT flows
- Accept
qscs_uuidandqscs_pubkeyas additional POST body fields at your token endpoint. - Validate the credentials and return
{"authenticated": true}from yourIdentityAuthUrlendpoint; QSCSCore handles device key storage automatically. - In your API middleware, trust
X-QSCS-Identityin addition to (or instead of) theAuthorization: Bearerheader. By the time the request arrives, the QSCS layer has already verified the ed25519 signature.
// Node.js / Express example
app.use("/api", function (req, res, next) {
const qscsUuid = req.headers["x-qscs-identity"];
if (qscsUuid) {
db.query(
"SELECT user_id FROM device_keys WHERE uuid = ? AND revoked = 0",
[qscsUuid],
function (err, rows) {
if (err || !rows.length) return res.status(401).json({ error: "unknown device" });
req.userId = rows[0].user_id;
next();
}
);
return;
}
// Fallback: legacy JWT for non-browser clients
const token = (req.headers.authorization || "").replace("Bearer ", "");
// … verify JWT, set req.userId …
});5d. Cookie-based session flows
- At login, start the session as normal and write the device key row.
- On session-gated routes, accept either a valid session cookie or a valid
X-QSCS-Identityheader. - Optionally require both: the session cookie and the QSCS identity must map to the same user. This eliminates session fixation and cookie theft in one step.
<?php
function require_auth(PDO $db): int {
$uuid = $_SERVER["HTTP_X_QSCS_IDENTITY"] ?? null;
if ($uuid !== null) {
$stmt = $db->prepare(
"SELECT user_id FROM device_keys WHERE uuid = ? AND revoked = 0"
);
$stmt->execute([$uuid]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) { http_response_code(401); exit; }
return (int)$row["user_id"];
}
// Fallback: session cookie
session_start();
if (empty($_SESSION["user_id"])) { http_response_code(401); exit; }
return (int)$_SESSION["user_id"];
}
?>X-QSCS-Identity header. QSCSCore removes any client-sent value before injecting the verified one. Your backend should also strip and ignore the header on any path that bypasses QSCSCore (e.g. during local development or internal service-to-service calls).5e. SPA / fetch-only applications
- Load the substrate and
qscs-crypto.jsin your HTML shell. - After
qscs:ready, call your login endpoint once to register the public key. All subsequentfetchcalls are signed automatically, no changes to existing API client code. - On the backend, add the device key table and UUID → user lookup. Remove the JWT/cookie check from endpoints that now receive a verified
X-QSCS-Identity.
6. Key lifecycle and revocation
Each browser instance holds one keypair. To revoke a specific device:
UPDATE device_keys SET revoked = 1 WHERE uuid = ?;QSCSCore returns 401 for any subsequent request from that UUID. The user re-registers by going through your login flow, which generates a new UUID and key pair.
To list active devices for a user:
SELECT uuid, created_at, last_seen
FROM device_keys
WHERE user_id = ? AND revoked = 0
ORDER BY last_seen DESC;Expose this list in your account settings so users can review and revoke their own registered devices.
7. Local development
During local development QSCSCore is typically not in the request path. qscs-crypto.js degrades gracefully: if qscs:ready does not fire within a configurable timeout, fetch calls proceed unsigned and your backend falls back to the cookie/JWT path as usual.
To disable signing entirely without removing the script tag:
window.QSCS_CRYPTO_CONFIG = { includePaths: [] };Summary checklist
| Step | Where |
|---|---|
Add device_keys table (uuid, user_id, public_key, revoked) | Database migration |
Add Auth=identity to each origin in qscs.conf | QSCSCore config |
Load qscs-substrate.js then qscs-crypto.js in HTML shell | Frontend HTML / bundle entry |
POST qscs_uuid + qscs_pubkey at login; write device key row | Login endpoint |
Read X-QSCS-Identity in middleware; map UUID to user | Backend middleware |
Strip any client-supplied X-QSCS-Identity before trusting it | Backend middleware |
| Expose device list + revocation in account settings | Account UI + API |