DocumentationBuild. Deploy. Operate.
DocsDeployment

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.

Who this is for Developers integrating Gated Identity into an existing web application, whether it currently uses OAuth 2.0, JWT bearer tokens, or server-side cookie sessions.

Components overview

ComponentWhere it runsWhat it does
qscs-crypto.jsBrowser (JS)Monkey-patches window.fetch; signs every same-origin request using the WASM substrate.
QSCS WASM substrateBrowser (WASM)Generates and persists an ed25519 keypair in IndexedDB; exports qscs_sign_request. The private key never leaves WASM linear memory.
QSCSCore daemonServer (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 applicationServer (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:

  1. The WASM module loads and checks IndexedDB for an existing keypair.
  2. 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.
  3. The module fires a qscs:ready DOM event. Your page (or qscs-crypto.js) waits for this event before making any authenticated request.

On every fetch() call to the same origin:

  1. qscs-crypto.js builds a canonical string: METHOD host path timestamp nonce.
  2. It calls the WASM export qscs_sign_request(canonical), which signs with the stored private key and returns a base64url signature.
  3. 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), and X-QSCS-Sig (base64url ed25519 signature).
  4. 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 returns 401 Unauthorized immediately.
Replay protection QSCSCore rejects any (UUID, nonce) pair it has seen before, and any request whose timestamp falls outside a ±60 second window. Clocks must be roughly synchronised, NTP is sufficient.

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-auth

On 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 browser

On 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=identity

Requests 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

  1. Accept qscs_uuid and qscs_pubkey as additional POST body fields at your token endpoint.
  2. Validate the credentials and return {"authenticated": true} from your IdentityAuthUrl endpoint; QSCSCore handles device key storage automatically.
  3. In your API middleware, trust X-QSCS-Identity in addition to (or instead of) the Authorization: Bearer header. 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 …
});
  1. At login, start the session as normal and write the device key row.
  2. On session-gated routes, accept either a valid session cookie or a valid X-QSCS-Identity header.
  3. 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"];
}
?>
Strip the header before checking it Never trust a client-supplied 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

  1. Load the substrate and qscs-crypto.js in your HTML shell.
  2. After qscs:ready, call your login endpoint once to register the public key. All subsequent fetch calls are signed automatically, no changes to existing API client code.
  3. 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

StepWhere
Add device_keys table (uuid, user_id, public_key, revoked)Database migration
Add Auth=identity to each origin in qscs.confQSCSCore config
Load qscs-substrate.js then qscs-crypto.js in HTML shellFrontend HTML / bundle entry
POST qscs_uuid + qscs_pubkey at login; write device key rowLogin endpoint
Read X-QSCS-Identity in middleware; map UUID to userBackend middleware
Strip any client-supplied X-QSCS-Identity before trusting itBackend middleware
Expose device list + revocation in account settingsAccount UI + API
Need a hand with your deployment?Contact support ↗Back to top ↑