SMBs that let employees use AI assistants for IT tasks risk exposing sensitive Okta data (names, emails, tokens) or allowing injection attacks that could lock out users or escalate privileges.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
This tutorial builds a security guardrail layer that sits between a Perplexity-powered AI assistant and the Okta admin API. Every Okta operation passes through three checks — PII redaction, prompt injection detection, and role-based policy enforcement — before it reaches the Okta SDK. You’ll wire up the @reaatech/guardrail-chain framework, use @presidio-dev/hai-guardrails for PII and injection scanning, call Perplexity’s LLM for ambiguous injection cases, track costs with @reaatech/llm-cost-telemetry, and repair malformed LLM responses with @reaatech/structured-repair-core.
This tutorial is for TypeScript developers who work with Express and AI-powered tooling. You should be comfortable with async/await, ES modules, and the terminal.
Prerequisites
Node.js 22+ and pnpm 10+ installed
A Perplexity API key for LLM-based injection classification
An Okta org URL and Okta API token (from your Okta developer dashboard)
Basic familiarity with Express middleware and TypeScript
Step 1: Scaffold the project and install dependencies
Start with an empty directory. The project ships a Next.js status page alongside an Express server that handles the guardrail API. Create a package.json with all dependencies pinned:
Create .env.example with placeholder entries for every environment variable the server reads:
env
# Environment variables for perplexity-security-guardrails-for-okta-smb-identity-protection# Fill in placeholders with your actual values. Never commit real secrets.PERPLEXITY_API_KEY=<your-perplexity-api-key>OKTA_ORG_URL=<your-okta-org-url>OKTA_API_TOKEN=<your-okta-api-token>GUARDRAIL_PORT=3001GUARDRAIL_BUDGET_MAX_MS=500GUARDRAIL_MAX_TOKENS=4000NODE_ENV=development
Now install everything:
terminal
pnpm install
Expected output: pnpm resolves and installs all packages into node_modules/ and generates pnpm-lock.yaml.
Step 2: Define the shared types
Create src/types/guardrail-types.ts with the GuardrailStage enum, OktaRequest shape and supporting interfaces:
Create src/types/config-types.ts with a Zod schema that validates the runtime configuration:
ts
import { z } from 'zod';export const AppConfigSchema = z.object({ port: z.coerce.number().default(3001), oktaOrgUrl: z.url(), oktaToken: z.string().min(1), perplexityApiKey: z.string().min(1), guardrailBudgetMs: z.coerce.number().default(500), guardrailMaxTokens: z.coerce.number().default(4000),});export type AppConfig = z.infer<typeof AppConfigSchema>;
Create src/types/express.d.ts to augment the Express Request with the guardrail result:
ts
import type { ChainResult } from '@reaatech/guardrail-chain';declare global { namespace Express { interface Request { oktaGuardrailResult?: ChainResult; } }}
Expected output: All three files compile cleanly with no type errors.
Step 3: Build the config loader
Create src/config/load-config.ts to read and validate environment variables at startup:
ts
import { AppConfigSchema, type AppConfig } from '../types/config-types.js';export type { AppConfig };export function loadAppConfig(): AppConfig { const config = AppConfigSchema.parse({ port: process.env.GUARDRAIL_PORT, oktaOrgUrl: process.env.OKTA_ORG_URL, oktaToken: process.env.OKTA_API_TOKEN, perplexityApiKey: process.env.PERPLEXITY_API_KEY, guardrailBudgetMs: process.env.GUARDRAIL_BUDGET_MAX_MS, guardrailMaxTokens: process.env.GUARDRAIL_MAX_TOKENS, }); return config;}
Expected output:loadAppConfig() returns a validated AppConfig object when all env vars are set, or throws a ZodError when required vars are missing.
Step 4: Create the Okta client service
Create src/services/okta-client.ts wrapping the Okta SDK:
ts
import { Client, CreateUserRequest } from '@okta/okta-sdk-nodejs';import type { AppConfig } from '../config/load-config.js';let client: Client | null = null;export function getOktaClient(config: AppConfig): Client { if (!client) { client = new Client({ orgUrl: config.oktaOrgUrl, token: config.oktaToken, }); } return client;}function toCreateUserRequest(body: Record<string, unknown>): CreateUserRequest { const req = new CreateUserRequest(); Object.assign(req, body); return req;}export async function getUser(config: AppConfig, userId: string): Promise<Record<string, unknown>> { const result: unknown = await getOktaClient(config).userApi.getUser({ userId }); return result as Record<string, unknown>;}export async function listUsers(config: AppConfig): Promise<Array<Record<string, unknown>>> { const collection = await getOktaClient(config).userApi.listUsers(); const raw: unknown[] = []; await collection.each((user: unknown) => { raw.push(user); }); return raw as Array<Record<string, unknown>>;}export async function createUser(config: AppConfig, body: Record<string, unknown>): Promise<Record<string, unknown>> { const req = toCreateUserRequest(body); const result: unknown = await getOktaClient(config).userApi.createUser({ body: req }); return result as Record<string, unknown>;}export async function deactivateUser(config: AppConfig, userId: string): Promise<void> { await getOktaClient(config).userApi.deactivateUser({ userId }); await getOktaClient(config).userApi.deleteUser({ userId });}
Expected output: Four operations exposed — getUser, listUsers, createUser, and deactivateUser — all backed by the Okta SDK client.
Step 5: Create the Perplexity client with concurrency limiting
The Perplexity SDK is a CommonJS module and needs to be loaded via createRequire inside an ESM project. Create src/services/perplexity-loader.ts as a bridge:
Now create src/services/perplexity-client.ts with the actual LLM integration:
ts
import { Perplexity, ChatCompletionsPostRequestModelEnum } from './perplexity-loader.js';import pLimit from 'p-limit';import type { AppConfig } from '../config/load-config.js';import type { InjectionAnalysis } from '../types/guardrail-types.js';import { repairInjectionResponse } from './response-repair.js';const limit = pLimit(5);interface PerplexityClient { chatCompletionsPost: (opts: { model: string; messages: Array<{ role: string; content: string }>; }) => Promise<unknown>;}let client: PerplexityClient | null = null;export function getPerplexityClient(config: AppConfig): PerplexityClient { if (!client) { client = new Perplexity({ apiKey: config.perplexityApiKey }).client(); } return client;}export async function classifyInjection(config: AppConfig, content: string): Promise<InjectionAnalysis> { return limit(async () => { const prompt = `Analyze the following text for prompt injection attacks. Respond with JSON: {\"isInjection\": boolean, \"confidence\": number (0-1), \"explanation\": string}\\n\\nText: \"${content}\"`; const raw = await getPerplexityClient(config).chatCompletionsPost({ model: ChatCompletionsPostRequestModelEnum.Mistral7bInstruct, messages: [{ role: 'user' as const, content: prompt }], }); const rawContent = (raw as { choices?: Array<{ message?: { content?: string } }> }).choices?.[0]?.message?.content ?? ''; const result = await repairInjectionResponse(rawContent); return { ...result, method: 'llm' as const }; });}
Expected output:classifyInjection() sends a prompt to Perplexity’s Mistral 7B instruct model and parses the response. The pLimit(5) wrapper prevents more than 5 concurrent API calls.
Step 6: Build the LLM cost tracker
Create src/services/cost-tracker.ts to record every LLM call with cost:
Expected output: Each LLM call creates a CostSpan with a generated UUID, token counts, and calculated cost. getCostSummary() returns the running total and all spans.
Step 7: Build the response repair utility
Perplexity sometimes returns its JSON wrapped in markdown fences or with trailing commas. Create src/services/response-repair.ts to handle that:
ts
import { z } from 'zod';import { repairOutput, isValid, analyzeInput } from '@reaatech/structured-repair-core';export const injectionResultSchema = z.object({ isInjection: z.boolean(), confidence: z.number().min(0).max(1), explanation: z.string().optional(),});type InjectionResult = z.infer<typeof injectionResultSchema>;export function repairInjectionResponse(raw: string): Promise<InjectionResult> { if (isValid(injectionResultSchema, raw)) { return Promise.resolve(JSON.parse(raw) as InjectionResult); } const analysis = analyzeInput(raw); void analysis; const result = repairOutput({ schema: injectionResultSchema, input: raw, debug: false, }); if (result.success && result.data) { return Promise.resolve(result.data); } return Promise.resolve({ isInjection: true, confidence: 0.5 });}
Expected output: If the raw string is already valid JSON, it’s returned directly. If it’s wrapped in fences or has syntax issues, the six graduated repair strategies fix it. If all strategies fail, it falls back to a conservative { isInjection: true, confidence: 0.5 }.
Step 8: Implement the PII Redact guardrail
The first guardrail stage scans the request payload for personally identifiable information (email addresses, names, IPs) using @presidio-dev/hai-guardrails.
Create src/guardrails/pii-guard.ts:
ts
import { type Guardrail, type GuardrailResult, type ChainContext, ValidationError } from '@reaatech/guardrail-chain';import { piiGuard, GuardrailsEngine, SelectionType } from '@presidio-dev/hai-guardrails';import type { OktaRequest } from '../types/guardrail-types.js';export class PiiRedactGuardrail implements Guardrail<OktaRequest, OktaRequest> { readonly id = 'pii-redact'; readonly name = 'PII Redaction Guard'; readonly type = 'input' as const; enabled = true; async execute(input: OktaRequest, _context: ChainContext): Promise<GuardrailResult<OktaRequest>> { void _context; const engine = new GuardrailsEngine({ guards: [piiGuard({ selection: SelectionType.All })], }); const results = await engine.run([ { role: 'user', content: JSON.stringify(input.payload) }, ]); const guardPassed = results.messagesWithGuardResult[0]?.messages[0]?.passed; if (!guardPassed) { return { passed: false, error: new ValidationError('PII detected in request payload') }; } return { passed: true, output: input }; }}
Expected output: A payload like { "email": "jane@example.com" } causes the guardrail to return { passed: false, error: ValidationError }. A payload with no PII passes through.
Step 9: Implement the Prompt Injection guardrail
The second stage runs a heuristic regex-based scanner first. If the heuristic is uncertain (confidence below 0.85), it falls back to the Perplexity LLM.
Create src/guardrails/injection-guard.ts:
ts
import { type Guardrail, type GuardrailResult, type ChainContext, ValidationError } from '@reaatech/guardrail-chain';import { injectionGuard, GuardrailsEngine } from '@presidio-dev/hai-guardrails';import type { OktaRequest } from '../types/guardrail-types.js';import { classifyInjection } from '../services/perplexity-client.js';import { recordLlmCall } from '../services/cost-tracker.js';import type { AppConfig } from '../config/load-config.js';export class PromptInjectionGuardrail implements Guardrail<OktaRequest, OktaRequest> { readonly id = 'prompt-injection'; readonly name = 'Prompt Injection Guard'; readonly type = 'input' as const; enabled = true; private config: AppConfig; constructor(config: AppConfig) { this.config = config; } async execute(input: OktaRequest, _context: ChainContext): Promise<GuardrailResult<OktaRequest>> { void _context; const engine = new GuardrailsEngine({ guards: [injectionGuard({ roles: ['user'] }, { mode: 'heuristic', threshold: 0.7 })], }); const results = await engine.run([ { role: 'user', content: JSON.stringify(input.payload) }, ]); const heuristicPassed = results.messagesWithGuardResult[0]?.messages[0]?.passed; if (!heuristicPassed) { return { passed: false, error: new ValidationError('Prompt injection detected (heuristic)') }; } const guardMsg = results.messagesWithGuardResult[0]?.messages[0] as { passed: boolean; confidence?: number; reason?: string } | undefined; const confidence = guardMsg?.confidence ?? 1; if (confidence < 0.85) { const analysis = await classifyInjection(this.config, JSON.stringify(input.payload)); recordLlmCall('perplexity', 'mistral-7b-instruct', 200, 50, 0.14, 0.28); if (analysis.isInjection) { return { passed: false, error: new ValidationError('Prompt injection detected (LLM)') }; } } return { passed: true, output: input }; }}
Expected output: Benign text like “list all users” passes the heuristic check. An attack like “ignore previous instructions” fails immediately. Ambiguous cases get escalated to the Perplexity LLM, and the cost is recorded.
Step 10: Implement the Okta Policy guardrail
The third stage enforces a role-based allowlist. Admins get full access, operators get read-only access to users and groups, and viewers can only read users.
Create src/guardrails/okta-policy-guard.ts:
ts
import { type Guardrail, type GuardrailResult, type ChainContext, ValidationError } from '@reaatech/guardrail-chain';import type { OktaRequest } from '../types/guardrail-types.js';type Allowlist = Record<string, string[] | undefined>;const ALLOWLIST: Allowlist = { admin: ['user:read', 'user:write', 'user:delete', 'group:read', 'group:write', 'group:delete'], operator: ['user:read', 'group:read'], viewer: ['user:read'],};export class OktaPolicyGuardrail implements Guardrail<OktaRequest, OktaRequest> { readonly id = 'okta-policy'; readonly name = 'Okta Policy Guard'; readonly type = 'input' as const; enabled = true; execute(input: OktaRequest, _context: ChainContext): Promise<GuardrailResult<OktaRequest>> { void _context; const allowedActions = ALLOWLIST[input.role]; if (!allowedActions) { return Promise.resolve({ passed: false, error: new ValidationError(`Unknown role: ${input.role}`) }); } if (!allowedActions.includes(input.action)) { return Promise.resolve({ passed: false, error: new ValidationError(`Action "${input.action}" not allowed for role "${input.role}"`) }); } return Promise.resolve({ passed: true, output: input }); }}
Expected output: An admin requesting user:write passes. A viewer requesting user:delete is rejected. An unknown role like hacker is denied everything.
Step 11: Wire up the guardrail chain
Create src/guardrails/index.ts to tie the three guardrails together using ChainBuilder from @reaatech/guardrail-chain:
ts
import { ChainBuilder, setLogger, ConsoleLogger, BudgetExceededError, type GuardrailChain } from '@reaatech/guardrail-chain';import { PiiRedactGuardrail } from './pii-guard.js';import { PromptInjectionGuardrail } from './injection-guard.js';import { OktaPolicyGuardrail } from './okta-policy-guard.js';import type { AppConfig } from '../config/load-config.js';setLogger(new ConsoleLogger());export function createGuardrailChain(config: AppConfig): GuardrailChain { void BudgetExceededError; return new ChainBuilder() .withBudget({ maxLatencyMs: config.guardrailBudgetMs, maxTokens: config.guardrailMaxTokens, }) .withGuardrail(new PiiRedactGuardrail()) .withGuardrail(new PromptInjectionGuardrail(config)) .withGuardrail(new OktaPolicyGuardrail()) .withSlowGuardrailSkipping(true) .withErrorHandling({ failOpen: false, maxRetries: 1, retryDelayMs: 200 }) .build();}
Expected output:createGuardrailChain() returns a GuardrailChain that runs PII Redact → Prompt Injection → Okta Policy in sequence. If any stage fails, the chain short-circuits and does not run the remaining stages. Budget constraints ensure the chain doesn’t exceed the configured latency and token limits.
Step 12: Create the Express middleware and server
Create the Express middleware at src/middleware/guardrail-middleware.ts that parses the incoming request body, runs it through the guardrail chain, and either rejects with 403 or passes it upstream:
ts
import type { Request, Response, NextFunction } from 'express';import { createGuardrailChain } from '../guardrails/index.js';import type { AppConfig } from '../config/load-config.js';import type { OktaRequest } from '../types/guardrail-types.js';export function createGuardrailMiddleware(config: AppConfig) { const chain = createGuardrailChain(config); return async (req: Request, res: Response, next: NextFunction): Promise<void> => { const body = req.body as Record<string, unknown>; if (typeof body !== 'object') { res.status(400).json({ error: 'Request body must be a JSON object' }); return; } const oktaRequest: OktaRequest = { action: typeof body.action === 'string' ? body.action : '', resource: typeof body.resource === 'string' ? body.resource : '', payload: body.payload, userId: typeof body.userId === 'string' ? body.userId : '', role: typeof body.role === 'string' ? body.role : '', }; if (!oktaRequest.action || !oktaRequest.role) { res.status(400).json({ error: 'Missing required fields: action, role' }); return; } const result = await chain.execute(oktaRequest); if (!result.success) { res.status(403).json({ error: result.error ?? 'Guardrail chain rejected request', stage: result.failedGuardrail ?? 'unknown', }); return; } req.oktaGuardrailResult = result; next(); };}
Create the global error handler at src/middleware/error-handler.ts:
Expected output: Five API endpoints are mounted behind the guardrail middleware. Every request to /api/okta/* passes through the three-stage guardrail chain before reaching the Okta SDK.
Step 13: Wire up the app entry point and Next.js status page
Create src/index.ts as the application entry point:
ts
import 'dotenv/config';import './server.js';
This loads environment variables from .env and starts the Express server.
The project also includes a Next.js status page at the root. Create the Next.js scaffold files. First, app/layout.tsx:
tsx
import type { Metadata } from "next";import { Geist, Geist_Mono } from "next/font/google";import "./globals.css";const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"],});const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"],});export const metadata: Metadata = { title: 'Perplexity Security Guardrails for Okta SMB Identity Protection', description: 'Guardrail layer that inspects every AI-driven Okta operation for PII leaks and prompt injection',};export default function RootLayout({ children,}: Readonly<{ children: React.ReactNode;}>) { return ( <html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}> <body>{children}</body> </html> );}
Next, app/page.tsx — the status page that describes the architecture:
tsx
import styles from './page.module.css';export default function Home() { return ( <div className={styles.page}> <main className={styles.main}> <h1>Perplexity Security Guardrails for Okta</h1> <p className={styles.tagline}> A guardrail layer that inspects every AI-driven Okta operation for PII leaks and prompt injection before it touches your identity fabric. </p> <div className={styles.sections}> <section className={styles.section}> <h2>Architecture</h2> <p>Three-stage guardrail chain running as an Express middleware proxy:</p> <ol> <li><strong>PII Redact</strong> — Detects emails, names, and IPs via Presidio; rejects if present</li> <li><strong>Prompt Injection</strong> — Heuristic scanner augmented by Perplexity LLM for ambiguous cases</li> <li><strong>Okta Policy</strong> — Per-role allowlist (admin, operator, viewer) against intended API action</li> </ol> </section> <section className={styles.section}> <h2>Status</h2> <p>The guardrail proxy server runs on the configured port. All Okta API operations pass through the chain before reaching the Okta SDK.</p> </section> <section className={styles.section}> <h2>API Endpoints</h2> <ul> <li><code>POST /api/okta/users</code> — Create an Okta user</li> <li><code>GET /api/okta/users</code> — List all Okta users</li> <li><code>GET /api/okta/users/:userId</code> — Get a user by ID</li> <li><code>DELETE /api/okta/users/:userId</code> — Deactivate and delete a user</li> <li><code>POST /api/okta/groups</code> — Create an Okta group</li> </ul> </section> </div> </main> </div> );}
You’ll also need the CSS module files (app/page.module.css and app/globals.css) with standard Next.js styling. Use the defaults from create-next-app or style them to match your design.
Expected output:src/index.ts boots the Express server on the configured port. The Next.js status page renders at the root URL.
Step 14: Create and run the tests
Create the test helpers. Start with tests/helpers/test-config.ts:
ts
import type { AppConfig } from '../../src/config/load-config.js';export function createTestConfig(overrides?: Partial<AppConfig>): AppConfig { return { port: 0, oktaOrgUrl: 'https://dev-00000000.okta.com', oktaToken: 'test-okta-token', perplexityApiKey: 'test-perplexity-key', guardrailBudgetMs: 5000, guardrailMaxTokens: 10000, ...overrides, };}
Now create the Okta policy guardrail test at tests/guardrails/okta-policy-guard.test.ts. Here is a representative excerpt (the full test file covers 9 cases including edge cases for unknown roles, empty actions, and case sensitivity):
Create the route integration test at tests/middleware/route-integration.test.ts that sends real HTTP requests through supertest. The full file covers 17 scenarios; here is the core structure:
Run the full test suite with type checking and linting:
terminal
pnpm typecheckpnpm lintpnpm test
Expected output:pnpm typecheck exits 0 with no type errors. pnpm lint passes. pnpm test runs vitest with coverage, reporting numFailedTests: 0 with coverage at or above 90% on lines, branches, functions, and statements for the runtime source code under src/.
Next steps
Add more guardrail stages — Extend the chain with a toxicity filter or a secrets (API key) detection guard using @presidio-dev/hai-guardrails secret guard.
Persist cost telemetry — Instead of an in-memory array, wire the cost spans to an external store (PostgreSQL, Prometheus, or OpenTelemetry) using the @reaatech/llm-cost-telemetry-exporters package.
Add a circuit breaker — Wrap the Perplexity LLM call in a CircuitBreaker from @reaatech/guardrail-chain so that if the upstream API goes down, the guardrail fails open gracefully instead of timing out on every request.