How to Add Proctoring to a React Assessment App

You add proctoring to a React app with the same framework-agnostic SDK everyone else uses: mint a session on your backend, then call ProctorLink.createSession({ jwt, sessionId }).start() inside a useEffect on mount and call stop() then destroy() in the cleanup on unmount. There is no React-specific package. The work is holding the session in a ref, wiring events once, and letting effect cleanup end the attempt. Every call below comes from the ProctorLink API reference.

Schedule a Demo

Where does the SDK fit in a React app?

The SDK is imperative and the camera lives in a cross-origin iframe enclave that mounts itself, so it does not belong in your render tree. It belongs in an effect. React’s job is to tell you when the exam component appears and disappears; the SDK’s job is to start when it appears and tear down when it goes. Map each SDK call to the React moment that should trigger it and the integration writes itself.

React momentWhat you doCall
Before the exam rendersMint a session on your backendPOST /v1/sessions
useEffect on mountCreate the session and begin captureProctorLink.createSession({ jwt, sessionId }).start()
useEffect cleanup on unmountFlush and tear downstop() then destroy()
On the token-expired eventRe-mint for the same attempt_id, swap the tokenupdateToken(jwt)
After the candidate submitsRead the integrity report on your backendGET /v1/sessions/:id

This is the same three-part split covered in How to add proctoring to a web application, applied to React’s lifecycle. If you are still choosing between the SDK, the REST API, and the Moodle plugin, start with Proctoring SDK vs API vs LMS plugin. New to the field? Read What is online proctoring? and What is AI proctoring? first.

Step 1: Mint the session on your backend

A React component cannot hold your API key safely, so minting stays on the server. In a Next.js App Router project this is a route handler or server action; in a separate backend it is an endpoint your React app calls. Send a stable attempt_id derived from your own attempt record, because that is what lets a refresh resume the same session instead of starting a new one.

// Your backend, or a Next.js server route / server action.
// The access-token and secret-token never reach the browser.
POST /v1/sessions
access-token: <YOUR_ACCESS_TOKEN>
secret-token: <YOUR_SECRET_TOKEN>
content-type: application/json

{
  "external_user_id": "candidate-123",
  "exam_id": "math-101-final",
  "attempt_id": "attempt-789",
  "allowed_origins": ["https://exams.yourcompany.com"]
}

// Response — pass only these to the React client
{
  "session_id": "6a7b18df70e4f8ecf2597b6f",
  "session_jwt": "eyJhbGciOi...",
  "expires_at": 1786443201
}

The browser only ever receives the session_jwt and session_id. Your access-token and secret-token stay on the server. The token lasts two hours by default; set ttl_seconds to your exam length plus a buffer and read the returned expires_at, because values outside the allowed range are clamped rather than rejected.

Step 2: Wrap the SDK in a hook

Put the whole lifecycle in one custom hook so your exam component stays about questions, not cameras. Create the session inside a useEffect, hold it in a useRef rather than state, wire the events once, and return a cleanup that ends the attempt. The ref keeps the SDK instance stable while the rest of your UI re-renders.

npm install @proctorlink/sdk@^0.1.3
// useProctoring.ts
import { useEffect, useRef } from 'react';
import { ProctorLink } from '@proctorlink/sdk';

type RemintResult = { session_jwt: string; session_id: string; resumed?: boolean };

type UseProctoringArgs = {
  jwt: string;          // session_jwt from your backend
  sessionId: string;    // session_id from your backend
  attemptId: string;    // stable per attempt, from your own record
  remint: (attemptId: string) => Promise<RemintResult>;
};

export function useProctoring({ jwt, sessionId, attemptId, remint }: UseProctoringArgs) {
  const sessionRef = useRef<ReturnType<typeof ProctorLink.createSession> | null>(null);

  useEffect(() => {
    const session = ProctorLink.createSession({ jwt, sessionId });
    sessionRef.current = session;

    session.on('permission', ({ camera }) => {
      if (camera === 'denied') { /* block or warn, your policy */ }
    });
    session.onEvent((event) => console.log(event.type, event));

    // Safety net for exams that outlast the token.
    session.on('token-expired', async () => {
      const res = await remint(attemptId);
      if (res.resumed) {
        session.updateToken(res.session_jwt);   // same session, carry on
      }
      // If res.resumed is false the old session ended — start a fresh one.
    });

    session.start();

    // Runs on unmount, and again between StrictMode's dev remount.
    return () => {
      session.stop();
      session.destroy();
      sessionRef.current = null;
    };
    // One attempt, one effect: identity comes from your backend, not re-renders.
  }, [jwt, sessionId, attemptId, remint]);

  return sessionRef;
}

Two events earn a listener. The permission event reports whether the candidate granted the camera, so you can enforce your own policy. The token-expired event fires only if a token dies mid-attempt, so capture never fails silently. The default flow records as soon as the camera is granted and uses the first captured frame as the identity reference. If you want matching against a deliberate photo instead, create the session with autoStartCapture: false, call captureIdentity() after start() resolves, then call beginCapture().

Step 3: Use the hook, and let cleanup end the attempt

The exam component just calls the hook. When the candidate submits, navigates away, or your router swaps the route, the component unmounts and React runs the cleanup you returned, which calls stop() then destroy(). That is the whole reason to lean on effect cleanup: it fires on every way out, so you never leave an enclave running.

// ProctoredExam.tsx  (mark 'use client' in the Next.js App Router)
'use client';

import { useProctoring } from './useProctoring';

type Props = {
  session: { jwt: string; id: string };   // fetched from your backend
  attempt: { id: string };                // your own attempt record
};

export function ProctoredExam({ session, attempt }: Props) {
  useProctoring({
    jwt: session.jwt,
    sessionId: session.id,
    attemptId: attempt.id,
    remint: (attempt_id) =>
      fetch('/api/proctoring/session', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ attempt_id }),
      }).then((r) => r.json()),
  });

  // The camera preview is the SDK's own floating enclave; render your exam freely.
  return <ExamQuestions attempt={attempt} />;
}

In React 18 StrictMode, development deliberately mounts, unmounts, and remounts the effect to surface missing cleanup. Because your cleanup fully tears the session down, the second mount starts cleanly rather than stacking a second camera preview. If your page sets a Content-Security-Policy, allow the enclave origin so the iframe can mount:

frame-src https://enclave.proctorlink.com;

Step 4: Keep long exams alive through token expiry

The session token is short-lived. For an exam that can outlast it, the token-expired handler in the hook re-mints on your backend for the same attempt_id. Because that attempt still has an active session, ProctorLink returns the existing session_id with a fresh token and resumed set to true, and you pass the new token to updateToken(). Events queued while the token was dead are flushed as soon as it lands, so nothing from the gap is lost.

Check resumed before calling updateToken(). A re-mint only resumes while the session is still active; if the abandonment sweep has already closed it, the mint creates a new session whose token belongs to a different attempt. The simplest way to avoid the whole situation is to set ttl_seconds generously at mint and treat token-expired as a safety net.

Step 5: Read the integrity report on your backend

After the attempt, your backend calls GET /v1/sessions/:id. Face analysis is deferred, not real-time, so fetch the report once status is validated, or equivalently when integrity.analysis_complete is true. Before that, face counts are zero and identity_match is pending, which is an unfinished result rather than a clean one.

// Your backend. Fetch once status is "validated"
// (or integrity.analysis_complete is true).
GET /v1/sessions/6a7b18df70e4f8ecf2597b6f
access-token: <YOUR_ACCESS_TOKEN>
secret-token: <YOUR_SECRET_TOKEN>

// Response (trimmed)
{
  "status": "validated",
  "integrity": {
    "score": 100,
    "level": "low",
    "flagged": false,
    "identity_match": "pass",
    "reasons": []
  }
}

The integrity.score starts at 100 and drops as penalties apply. The level is low at 80 or above, medium from 50 to 79, and high below 50, and flagged turns true below 80. The verdict is computed server-side only, so a candidate cannot influence it from the React app no matter what they do in the browser. A score alone should not fail anyone: use reasons and evidence to drive human review of anything flagged.

Common mistakes in a React integration

  • Storing the session in useState. The session is an imperative handle, not render state. Putting it in state triggers re-renders and leaves you with stale closures when you later call updateToken(). Fix: hold it in a useRef and read sessionRef.current from your handlers.
  • Creating the session in the render body. Anything outside an effect runs on every render, and in the Next.js App Router it can run on the server, where there is no camera. Fix: only call ProctorLink.createSession() inside a useEffect, and mark the component 'use client'.
  • Missing effect cleanup. Without a cleanup that calls stop() then destroy(), StrictMode doubles the camera preview in development and, worse, real navigation leaves an enclave running. Fix: return the cleanup from useEffect.
  • Generating attempt_id on render. A value created with useState(() => crypto.randomUUID()) or on each mount defeats resume, so every refresh mints a new session and bills the attempt twice. Fix: derive attempt_id from your own attempt record so it is stable for the attempt.
  • Refreshing a token by remounting. Unmounting the component to “restart” proctoring ends the attempt server-side, and the next mint creates a separate session: two reports for one attempt. Fix: handle token-expired and call updateToken() with a freshly minted token for the same attempt_id.

Why the design keeps your React app safe

The split matters more in a single-page app, where it is tempting to keep everything in the client. Your API key is server-side only. The browser receives a short-lived, origin-bound session token, and that token lives inside the cross-origin iframe enclave served from ProctorLink’s own domain rather than in your React state or props. The camera permission binds to the enclave origin, so a candidate who has already granted it on one exam is not prompted again on another site that embeds ProctorLink. The integrity score is computed server-side only, which is the core anti-tamper property: a candidate cannot edit their own score from a React devtools console.

Keyframes upload from the browser straight to object storage through presigned URLs, so image bytes never transit the API, and only periodic stills are captured rather than continuous video. If your exams run inside Moodle instead of your own React app, you can skip this integration and use the drop-in plugin covered in Best Moodle proctoring plugin. Across published deployments, ProctorLink has supported more than one million proctored exam sessions (methodology note below). Full cohort sizes and outcomes are on the case studies page.

What customers say on G2

Institutions evaluating proctoring tools often look for independent feedback outside vendor case studies. ProctorLink is listed on G2, where Moodle administrators and training teams share verified product reviews.

Read ProctorLink reviews on G2 →

Frequently Asked Questions

No, and you do not need one. ProctorLink ships a single browser SDK, @proctorlink/sdk, that is framework-agnostic. React integration is a matter of where you call it: mint the session on your backend, then call ProctorLink.createSession({ jwt, sessionId }).start() inside a useEffect on mount, and call stop() then destroy() in the effect cleanup on unmount. The SDK mounts its own cross-origin iframe enclave for the camera, so it does not fight React's rendering or need a wrapper component.

Sources & references

Deployment statistics and product behaviour described in this guide link to the sources below.

Next steps

Ready to proctor exams in your React app? Get credentials, wire the hook above into a real attempt, and check the reviewer workload before you commit.

More Proctoring Guides

Proctor Exams in Your React App

Your backend mints a session, a custom hook runs it inside an effect, and you read a server-side integrity score. Pilot the SDK on a real attempt and check the reviewer workload before you commit.