How to Add Proctoring to an Angular Exam Portal

You add proctoring to an Angular app with the same framework-agnostic SDK everyone else uses: mint a session on your backend, then call ProctorLink.createSession({ jwt, sessionId }).start() from a component’s ngOnInit (usually through an injectable service) and call stop() then destroy() in ngOnDestroy. There is no Angular-specific package. The work is holding the session in a service, wiring events once, and letting ngOnDestroy end the attempt. Every call below comes from the ProctorLink API reference.

Schedule a Demo

Where does the SDK fit in an Angular app?

The SDK is imperative and the camera lives in a cross-origin iframe enclave that mounts itself, so it does not belong in a template. It belongs in a component’s lifecycle hooks, and the session itself belongs in an injectable service. Angular’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 Angular moment that should trigger it and the integration writes itself.

Angular momentWhat you doCall
Before the exam route loadsMint a session on your backendPOST /v1/sessions
Component ngOnInitCreate the session and begin captureProctorLink.createSession({ jwt, sessionId }).start()
Component ngOnDestroyFlush 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 Angular’s lifecycle. If your exams run in a different framework, the React assessment app guide covers the same calls with hooks. 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

An Angular component cannot hold your API key safely, so minting stays on the server. Your Angular app calls its own backend endpoint through HttpClient, and that endpoint calls POST /v1/sessions. 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 server route the Angular app calls.
// 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 Angular 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: Hold the SDK in an injectable service

Put the whole lifecycle in one service so your exam component stays about questions, not cameras. Create the session inside a start() method, hold the instance on the service, wire the events once, and expose a stop() the component can call. A service also survives navigation when you provide it at the right level, which matters when one attempt spans several routes.

npm install @proctorlink/sdk

The loader is published on npm as @proctorlink/sdk, so it installs like any other dependency and works with npm, pnpm, or yarn. You get your API keys when you sign up at app.proctorlink.com.

// proctoring.service.ts
import { Injectable, NgZone } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { ProctorLink } from '@proctorlink/sdk';

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

@Injectable({ providedIn: 'root' })
export class ProctoringService {
  private session: ReturnType<typeof ProctorLink.createSession> | null = null;

  constructor(private http: HttpClient, private zone: NgZone) {}

  async start(jwt: string, sessionId: string, attemptId: string) {
    const session = ProctorLink.createSession({ jwt, sessionId });
    this.session = session;

    session.on('permission', ({ camera }) => {
      if (camera === 'denied') {
        // Update the view from inside the zone so change detection runs.
        this.zone.run(() => { /* 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 this.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.
    });

    await session.start();
  }

  stop() {
    this.session?.stop();
    this.session?.destroy();
    this.session = null;
  }

  private remint(attemptId: string): Promise<MintResult> {
    // Your own backend endpoint that calls POST /v1/sessions.
    return firstValueFrom(
      this.http.post<MintResult>('/api/proctoring/session', { attempt_id: attemptId }),
    );
  }
}

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. Both callbacks can run outside Angular’s zone, so wrap any part that updates the view in NgZone.run(). 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: Start in ngOnInit, tear down in ngOnDestroy

The exam component just calls the service. Start proctoring in ngOnInit, and stop it in ngOnDestroy. When the candidate submits, navigates away, or the router swaps the route, Angular destroys the component and runs ngOnDestroy, which calls stop() then destroy() through the service. That is the whole reason to lean on ngOnDestroy: it fires on every way out, so you never leave an enclave running.

// proctored-exam.component.ts
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { ProctoringService } from './proctoring.service';

@Component({
  selector: 'app-proctored-exam',
  standalone: true,
  templateUrl: './proctored-exam.component.html',
})
export class ProctoredExamComponent implements OnInit, OnDestroy {
  @Input() session!: { jwt: string; id: string };   // fetched from your backend
  @Input() attempt!: { id: string };                 // your own attempt record

  constructor(private proctoring: ProctoringService) {}

  ngOnInit(): void {
    // Camera comes up as its own floating enclave; render your exam freely.
    this.proctoring.start(this.session.jwt, this.session.id, this.attempt.id);
  }

  ngOnDestroy(): void {
    this.proctoring.stop();   // stop() then destroy(), inside the service
  }
}

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 service 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 Angular 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 an Angular integration

  • Creating the session in the constructor or a field initializer. A constructor runs during Angular Universal server render, where there is no camera, and before the view exists. Fix: create the session in ngOnInit (or behind isPlatformBrowser), never in the constructor or a class field.
  • Providing the service on the exam component when an attempt spans routes. A service listed in a component’s providers is destroyed and recreated on navigation, so a multi-route attempt tears the session down and re-mints. Fix: provide it with providedIn: 'root' or at a parent that survives the whole attempt.
  • Missing ngOnDestroy cleanup. Without a hook that calls stop() then destroy(), navigating between exams leaves an enclave running and can stack a second camera preview. Fix: implement OnDestroy and stop the session there.
  • Updating the view from an event without NgZone. The permission, event, and token-expired callbacks can fire outside Angular’s zone, so a field you set from them may not refresh the template. Fix: wrap the UI update in NgZone.run(), or call markForCheck() with OnPush.
  • Generating attempt_id on init. A value created with crypto.randomUUID() in ngOnInit 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 recreating the component. Re-navigating to “restart” proctoring runs ngOnDestroy, which 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 Angular 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 an Angular service field or component input. 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 the browser 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 Angular 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 framework-agnostic browser SDK, @proctorlink/sdk. Angular integration is about where you call it: mint the session on your backend, then call ProctorLink.createSession({ jwt, sessionId }).start() from a component's ngOnInit (or a service it injects), and call stop() then destroy() in ngOnDestroy. The SDK mounts its own cross-origin iframe enclave for the camera, so it does not need to live in your template or fight change detection.

Sources & references

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

Next steps

Ready to proctor exams in your Angular app? Sign up at app.proctorlink.com to get your API keys, wire the service above into a real attempt, and check the reviewer workload before you commit.

More Proctoring Guides

Proctor Exams in Your Angular App

Your backend mints a session, an injectable service runs it across the component lifecycle, and you read a server-side integrity score. Pilot the SDK on a real attempt and check the reviewer workload before you commit.