Skip to main content
The first two tutorials cover authors reading their own documents (embed token) and fully public documents (share token). This one covers the case in between: another signed-in user reads a document, and your app decides whether they may — a student viewing a teacher’s lesson, a member viewing club-only content. Embed tokens can’t express this: they are scoped to one identity’s own documents, and minting the author’s token for a reader would let them edit. Instead, the renderer accepts a per-document, expiring HMAC signature that your backend computes locally — no network call — after running whatever access check you like:
signature = hex(HMAC_SHA256(signing_secret, "<document_id>|<exp>"))
Qirtaas holds the same signing secret and recomputes the HMAC to verify. The signature is your backend’s short-lived approval stamp: “this reader may read this document until exp”. The ACL itself — enrolments, roles, payments — never leaves your database. The example: a course platform. Teachers author lesson documents (the creation flow, unchanged); enrolled students read them. This mirrors how production hosts embed Qirtaas. Prerequisite: your organization’s signing secret, provisioned alongside the API key. Like the secret key, it must never reach the browser.

1. Backend — the data model

The first two tutorials needed no host-side model — each user’s embed identity was the whole access story. ACL is where one becomes necessary: before your backend can decide to authorize a read, it has to know which document belongs to which lesson. So store the pointer:
lessons(id, course_id, title, qirtaas_doc_id, …)
enrolments(user_id, course_id, …)
A lesson row holds its qirtaas_doc_id (recorded from onDocumentCreated when the teacher authors it — the content itself stays in Qirtaas); enrolments are whatever your ACL already is — the signature flow doesn’t care about its shape, only that you can answer “may this user read this lesson?“.

2. Backend — a signing helper

The signature is computed locally with your platform’s standard crypto library. The returned shape { signature, exp } matches the SDK renderer’s getSignature contract directly, so you can hand the response straight through.
// env: QIRTAAS_SIGNING_SECRET=…
import { createHmac } from "node:crypto";

const SIGNING_SECRET = process.env.QIRTAAS_SIGNING_SECRET!;

export function signRead(docId: string, ttlSeconds = 3600) {
  const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
  const signature = createHmac("sha256", SIGNING_SECRET)
    .update(`${docId}|${exp}`)
    .digest("hex");
  return { signature, exp };
}

3. Backend — the gated signature endpoint

This endpoint is where your ACL and Qirtaas meet: authenticate the caller, run the access check, and only then sign. A signature is an unconditional read grant for that document until exp — the check must happen before it is minted, because Qirtaas cannot re-ask you.
import { signRead } from "./qirtaasSigning";

app.get("/api/lessons/:id/signature", requireAuth, async (req, res) => {
  const lesson = await db.lessons.findUnique({
    where: { id: Number(req.params.id) },
  });
  if (!lesson) return res.status(404).json({ error: "Lesson not found" });

  // The ACL check — entirely yours. Here: must be enrolled in the course.
  const enrolled = await db.enrolments.findFirst({
    where: { user_id: req.user.id, course_id: lesson.course_id },
  });
  if (!enrolled) return res.status(403).json({ error: "not_enrolled" });

  res.json(signRead(lesson.qirtaas_doc_id)); // { signature, exp }
});
Alternatively, inline the signature into the response your lesson endpoint already returns ({ lesson: …, signature: … }) and save the round trip — the renderer’s getSignature can resolve from anywhere. A separate endpoint keeps this tutorial composable.

4. Frontend — render with getSignature

Give the renderer getSignature instead of getToken. The renderer sends the pair as ?sig=&exp= query parameters on its read (signed-URL style, so cross-origin reads stay CORS-simple):
src/LessonView.tsx
import { QirtaasRenderer } from "@qirtaas/react";

export function LessonView({ lesson }: { lesson: Lesson }) {
  async function getSignature() {
    const res = await fetch(`/api/lessons/${lesson.id}/signature`);
    if (!res.ok) throw new Error(`signature fetch failed (${res.status})`);
    return res.json(); // { signature, exp }
  }

  return (
    <QirtaasRenderer
      key={lesson.qirtaas_doc_id}
      documentId={lesson.qirtaas_doc_id}
      getSignature={getSignature}
      locale="ar"
      theme="light"
      onError={(code) => console.error("render failed:", code)}
    />
  );
}
The student never receives a token, never gets a Qirtaas identity, and can’t write anything — the signature authorizes exactly one document, read-only, until exp.

5. Handle expiry

Unlike embed tokens, signatures are not refreshable: an expired or invalid one is a hard 403 { "error": "invalid_signature" }, surfaced through the renderer’s onError. Two practical consequences:
  • Pick a TTL that covers a reading session — an hour is a sensible default. Short TTLs add security margin but expire under long reads.
  • Recover by remounting. On the error, fetch a fresh signature and remount the renderer (bump a key). If the signature endpoint itself returns 403, the user genuinely lost access — show your paywall or enrolment prompt instead.
const [attempt, setAttempt] = useState(0);

<QirtaasRenderer
  key={`${lesson.qirtaas_doc_id}:${attempt}`}
  documentId={lesson.qirtaas_doc_id}
  getSignature={getSignature}
  onError={(code) => {
    if (code === "invalid_signature") setAttempt((n) => n + 1);
  }}
/>

6. Going further — drafts and publishing

Signatures compose with a two-pointer pattern for draft/publish flows, where the ACL question isn’t just who may read but which version they may read. Give the lesson two document pointers:
lessons(…, qirtaas_doc_id, draft_doc_id)
  • Only ever sign qirtaas_doc_id — the published pointer. Drafts sit in draft_doc_id, which the signature endpoint never signs, so students simply cannot read them: the read gate is the signing decision.
  • Authoring a new lesson writes the fresh document id into draft_doc_id; publishing promotes it (qirtaas_doc_id = draft_doc_id; draft_doc_id = "") in one transaction.
  • Revising a published lesson: the teacher’s frontend clones the live document with the client’s duplicateDocument and registers the clone as the new draft. Students keep reading the stable published version while the teacher edits the clone; publishing promotes it and the old published document becomes an orphan the frontend deletes via deleteDocument.
// Teacher clicks "Revise" on a published lesson:
const { id: draftId } = await qirtaas.duplicateDocument(lesson.qirtaas_doc_id);
await fetch(`/api/lessons/${lesson.id}/draft`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ draft_doc_id: draftId }),
});
// …then mount <QirtaasEditor documentId={draftId}> as usual.
Note the division of labor holds throughout: document lifecycle (create/clone/delete) runs on the frontend over the embed channel; your backend only records pointers and decides what to sign.

Security checklist

  • The signing secret lives in backend env/config only — it is as sensitive as the API key.
  • Check access before signing, and sign only the specific document the check covered. Never sign ids taken from the request unchecked.
  • Signatures grant read-only access to one document — there is no wildcard signature, so a leaked one is contained to a single document until its exp.
Full reference: Authentication — signatures.