DEV Community

Cover image for Keeping Hyperframes Render Requests Behind a Secure Next.js Boundary
Elena Rostova
Elena Rostova

Posted on

Keeping Hyperframes Render Requests Behind a Secure Next.js Boundary

Keeping Hyperframes Render Requests Behind a Secure Next.js Boundary

At 3:00 AM, your on-call pager wakes you: rendering credentials leaked into a client bundle, a customer's video quota exhausted by replayed requests, or worse—arbitrary HTML rendering with full cloud metadata access. HTML-to-video looks straightforward until rendering becomes a privileged operation. A browser submits a template identifier, a server schedules work, and a worker produces a video. The dangerous shortcut is letting the browser submit arbitrary HTML, renderer options, or service credentials along the way.

As an external architect evaluating Hyperframes, I treat its "Write HTML. Render video." model as a rendering boundary, not an authorization boundary. The integration below is an application-side design, not a claim about Hyperframes' internal APIs.

The developer pain point is keeping render submission secure without making the dashboard wait for rendering infrastructure.

Separate submission from execution

The request path should be: browser → Next.js Server Action → PostgreSQL job → isolated render worker. The worker owns the Hyperframes integration. Next.js owns session verification, template ownership, and durable acceptance.

That separation addresses two failures tutorials routinely overlook.

One incident pattern starts with a shared configuration module re-exported through a "use client" entry point. A credential copied into an exported configuration object can reach the browser bundle. Next.js does not automatically expose ordinary server environment variables, but explicit serialization and public build-time configuration still leak secrets. Put privileged dependencies behind server-only; inspect generated client bundles before rotating a compromised key.

The performance counterpart is a dashboard that awaits recent jobs before returning its submission form. That creates a data waterfall and can delay the page's largest visible content. A nested Suspense boundary lets the form stream immediately while job history loads independently.

A minimal application-side implementation

This assumes an existing Next.js App Router application and PostgreSQL. Install zod, jose, pg, and server-only, plus @types/pg for development.

Provision DATABASE_URL and a random, minimum-32-byte SESSION_SECRET through your deployment's secret manager. Your login service must issue an HS256 JWT cookie named session, with issuer render-app, audience render-dashboard, a user UUID in sub, and an expiration. Set that cookie HttpOnly, Secure, and SameSite=Lax.

Apply the SQL first, then create the labeled files:

/* Database migration:
CREATE TABLE templates (
  id uuid PRIMARY KEY,
  owner_id uuid NOT NULL,
  html text NOT NULL
);
CREATE TABLE render_jobs (
  id uuid PRIMARY KEY,
  owner_id uuid NOT NULL,
  template_id uuid NOT NULL REFERENCES templates(id),
  status text NOT NULL DEFAULT 'queued',
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX render_jobs_owner_created
  ON render_jobs(owner_id, created_at DESC);
*/

// lib/server.ts
import "server-only";
import { Pool } from "pg";
import { jwtVerify } from "jose";
import { cookies } from "next/headers";
import { z } from "zod";

const globalDb = globalThis as typeof globalThis & { db?: Pool };
export const db = globalDb.db ??= new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 5,
  connectionTimeoutMillis: 3000,
  statement_timeout: 5000,
});

export async function userId(): Promise<string | null> {
  const secret = process.env.SESSION_SECRET;
  if (!secret || Buffer.byteLength(secret) < 32) {
    throw new Error("Invalid session configuration");
  }
  const token = (await cookies()).get("session")?.value;
  if (!token) return null;
  try {
    const { payload } = await jwtVerify(
      token, new TextEncoder().encode(secret),
      {
        algorithms: ["HS256"],
        issuer: "render-app",
        audience: "render-dashboard",
        requiredClaims: ["exp", "sub"],
      },
    );
    return z.string().uuid().parse(payload.sub);
  } catch {
    return null;
  }
}

// app/actions.ts
"use server";
import { randomUUID } from "node:crypto";
import { z } from "zod";
import { db, userId } from "../lib/server";

export type State =
  | { kind: "idle" }
  | { kind: "queued"; jobId: string }
  | {
      kind: "error";
      code: "AUTH" | "INPUT" | "UNAVAILABLE";
      message: string;
    };

export async function enqueue(
  _: State, form: FormData,
): Promise<State> {
  try {
    const owner = await userId();
    if (!owner) {
      return { kind: "error", code: "AUTH", message: "Sign in again." };
    }
    const parsed = z.string().uuid().safeParse(form.get("templateId"));
    if (!parsed.success) {
      return { kind: "error", code: "INPUT", message: "Invalid template." };
    }
    const result = await db.query<{ id: string }>(
      `INSERT INTO render_jobs (id, owner_id, template_id)
       SELECT $1, $2, id FROM templates
       WHERE id = $3 AND owner_id = $2 RETURNING id`,
      [randomUUID(), owner, parsed.data],
    );
    if (!result.rows[0]) {
      return { kind: "error", code: "INPUT", message: "Template unavailable." };
    }
    return { kind: "queued", jobId: result.rows[0].id };
  } catch {
    return {
      kind: "error", code: "UNAVAILABLE",
      message: "Submission unavailable. Try again later.",
    };
  }
}

// app/render-form.tsx
"use client";
import { useActionState } from "react";
import { enqueue, type State } from "./actions";

export function RenderForm() {
  const [state, action, pending] = useActionState<State, FormData>(
    enqueue, { kind: "idle" },
  );
  return (
    <form action={action}>
      <label>Template UUID <input name="templateId" required /></label>
      <button disabled={pending}>Queue render</button>
      <p role="status">
        {state.kind === "error" ? state.message :
          state.kind === "queued" ? `Queued ${state.jobId}` : ""}
      </p>
    </form>
  );
}

// app/page.tsx
import { Suspense } from "react";
import { redirect } from "next/navigation";
import { db, userId } from "../lib/server";
import { RenderForm } from "./render-form";

export const runtime = "nodejs";

async function RecentJobs({ owner }: { owner: string }) {
  const { rows } = await db.query<{ id: string; status: string }>(
    `SELECT id, status FROM render_jobs
     WHERE owner_id = $1 ORDER BY created_at DESC LIMIT 10`,
    [owner],
  );
  return <ul>{rows.map(job =>
    <li key={job.id}>{job.id}: {job.status}</li>,
  )}</ul>;
}

export default async function Page() {
  const owner = await userId();
  if (!owner) redirect("/login");
  return (
    <main>
      <h1>Render dashboard</h1>
      <RenderForm />
      <Suspense fallback={
        <div aria-busy="true" aria-label="Loading recent renders"
          style={{ minHeight: 240, background: "#eee" }} />
      }>
        <RecentJobs owner={owner} />
      </Suspense>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

What this boundary buys—and costs

The authorization predicate lives inside the insert. A guessed template UUID cannot enqueue another customer's template. The browser receives a job identifier, never rendering credentials or HTML.

The database row is the queue, avoiding a database-write/message-publish dual-write failure. A worker still needs atomic claiming, leases, bounded retries, and failure recording. At higher throughput, that operational burden may justify a dedicated queue.

Submission is not idempotent: retries can create duplicate jobs. Before billing per render, add a validated request identifier and a unique owner/request constraint. Also apply per-user quotas before accepting work.

Finally, stored HTML is not inherently safe. Run rendering with restricted network access, blocked cloud metadata endpoints, resource limits, and no application secrets. Suspense improves delivery timing; it does not make database queries faster or guarantee better LCP. Measure the actual page while keeping slow history queries outside the critical rendering path.

The hardest trade-off

The single hardest decision here is where to draw the trust boundary when stored HTML can still perform network requests, enumerate local resources, or exploit renderer bugs. Sandboxing the render worker adds latency and infrastructure complexity. Not sandboxing risks lateral movement if a customer template is compromised or deliberately malicious.

How do you balance rendering flexibility against isolation cost? Do you parse and sanitize HTML server-side, or do you isolate the renderer and accept the performance hit? What's your threshold for treating user-supplied templates as hostile input?

Top comments (0)