DEV Community

Zurox
Zurox

Posted on

The Browser Is Not the Source of Truth: Signing a Calculation Context in Next.js

A client-side preview is often the right product choice. A person can see a result immediately, decide whether it is useful, and only then choose to pay for a longer report.

It is the wrong place to establish the facts that a paid workflow will fulfill.

I ran into this while building AstroZen, a Next.js application with a free calculated chart and an optional paid Dossier. The browser needs the calculation context to render the preview. But if checkout accepts that context as truth, a modified browser request can change the data attached to the paid order.

The pattern I used is small:

validated input
  -> server calculation
  -> context + HMAC signature
  -> browser preview
  -> checkout verifies the same context + signature
  -> server persists an immutable order intent
Enter fullscreen mode Exit fullscreen mode

The important distinction is that the signature proves integrity. It does not turn a browser into a trusted system, authorize payment, or solve every replay problem.

Key takeaway: Integrity proves the context was not tampered with. It does not establish freshness, authorization, or payment status on its own.

The problem is not the UI

The free preview returns a structured object that is useful to show in the browser. It contains the facts a longer report will later use: normalized input, chart outputs, calculation metadata, and explicit limitations.

An early design could send that object straight from the browser to a payment endpoint:

await fetch("/api/checkout-intent", {
  method: "POST",
  body: JSON.stringify({ email, context }),
});
Enter fullscreen mode Exit fullscreen mode

That is convenient, but context is now untrusted input. A user can alter it in DevTools, replay a request, or use a script that never rendered the preview at all. Client-side validation and a disabled button do not change that.

For a paid calculation, the server should establish the context once and later reject any client-supplied version that does not match it.

Sign the server result, not the raw form

AstroZen calculates its DossierContext on the server. The calculation route then returns the result and an HMAC over the exact context:

// src/lib/server/context-signature.ts
import crypto from "node:crypto";

function signingSecret(): string {
  const secret = process.env.INTERNAL_QUEUE_SECRET;
  if (!secret || secret.length < 32) {
    throw new Error("Calculation signing secret is unavailable");
  }
  return secret;
}

export function signContext(context: DossierContext): string {
  return crypto
    .createHmac("sha256", signingSecret())
    .update(JSON.stringify(context))
    .digest("base64url");
}
Enter fullscreen mode Exit fullscreen mode

The route returns both values:

// src/app/api/calculate/route.ts
const result = await calculateDossier(input);

return Response.json({
  ...result,
  contextSignature: signContext(result.context),
});
Enter fullscreen mode Exit fullscreen mode

The browser can render result.context, but it cannot produce a valid signature for a changed context. At checkout, the server recomputes the HMAC and compares it in constant time:

export function verifyContext(
  context: DossierContext,
  supplied: string,
): boolean {
  if (!supplied) return false;

  const expected = signContext(context);
  const left = Buffer.from(expected);
  const right = Buffer.from(supplied);

  return left.length === right.length && crypto.timingSafeEqual(left, right);
}
Enter fullscreen mode Exit fullscreen mode

If verification fails, checkout stops and asks the client to calculate again. A context with a changed time zone, item, or calculated field is rejected before an order is created.

Serialization is part of the contract

An HMAC signs bytes, not an abstract JavaScript object. In the example above, those bytes come from JSON.stringify(context).

That is acceptable for this narrow round trip because the server generates the object, the browser sends the same JSON structure back, and the route does not rely on a language-neutral signature format. It is still a contract worth treating carefully.

For a wider system, I would make the signed payload explicit:

  • Add a schema version to the payload.
  • Use canonical JSON or a typed encoding if another runtime will verify it.
  • Sign only the fields checkout needs, rather than an ever-growing display object.
  • Add an expiry and check it on the server if a calculation should only be purchasable for a limited time.

The last point matters in this implementation. The calculation context includes a generation timestamp, but the signature alone does not enforce an expiration. Integrity is not freshness.

Recompute pricing and consent on the server

Valid context does not mean that every other checkout field is trusted.

The checkout route validates the email format and the required consent fields, then decides the price tier from the server's own order history:

// src/app/api/checkout-intent/route.ts
const hasPaidOrder = await hasUsedIntroductoryPrice(supabase, email);
const pricing = pricingForPaidHistory(hasPaidOrder);

if (pricing.tier === "standard" && body.acceptStandardPrice !== true) {
  return Response.json(
    { error: "The introductory price has already been used." },
    { status: 409 },
  );
}
Enter fullscreen mode Exit fullscreen mode

The browser can acknowledge a price change, but it does not choose the amount or payment-provider product ID. Those values come from server configuration after the signed calculation context has passed verification.

This is the boundary I want in any paid flow:

Client may provide Server must decide
Email and consent selections Price, currency, and product ID
A previously signed calculation context Whether that context is intact
A request to start checkout The order ID and payment session

Persist an intent before redirecting away

Payment providers require a redirect or hosted checkout session. Before creating it, the server creates an order in its database with a fresh request ID, the verified context, expected product, expected amount, currency, and the consent record.

// src/app/api/checkout-intent/route.ts
const requestId = crypto.randomUUID();

const { data: order, error } = await supabase
  .from("orders")
  .insert({
    request_id: requestId,
    email,
    context: body.context,
    status: "pending",
    expected_product_id: expectedProductId,
    expected_amount: expectedAmount,
    expected_currency: "USD",
  })
  .select("id")
  .single();
Enter fullscreen mode Exit fullscreen mode

The payment provider receives the opaque requestId, not the whole calculation payload. After payment, the webhook verifies its own provider signature and checks that the completed checkout matches the stored order's expected product, amount, and currency.

That gives the webhook an independent server-side record to compare against. The success page is then a display surface, not proof of payment.

Treat delivery links as credentials

The redirect needs a way to show the completed order without exposing a permanent database identifier. I generate a random access token, store only an HMAC of that token, and attach the raw token to the success URL.

// src/lib/order-access.ts
const rawToken = `az_v${version}_${crypto
  .randomBytes(32)
  .toString("base64url")}`;

const tokenHash = crypto
  .createHmac("sha256", pepperFor(version))
  .update(rawToken)
  .digest("hex");
Enter fullscreen mode Exit fullscreen mode

The database never needs the raw token. On a download request, the server hashes the submitted token with the matching pepper version, checks that stored hash, verifies expiration and revocation state, and then issues a short-lived storage URL.

This also makes key rotation practical: token versions select the correct pepper while a newer version becomes the default for new links.

What the HMAC does not solve

It is tempting to call a signed JSON object “secure” and stop there. That would hide important remaining controls.

An HMAC does not by itself provide:

  • Authorization. The checkout route still needs to decide which user action is allowed and which price applies.
  • Freshness. A signature without a verified expiry can remain valid longer than intended.
  • Replay prevention. The route needs rate limits, idempotency rules, or one-time server records when repeated use matters.
  • Payment proof. Only a verified provider webhook plus the stored intent establishes a completed purchase.
  • Schema safety. A display-model change can invalidate a naive serialization scheme if the signing contract is not versioned.

This app has request-size limits, and its endpoints apply IP-based rate limits when Upstash is configured. Each checkout also creates a fresh server-side order intent. I would add a server-checked expiry to the signed context before relying on the signature for a time-sensitive offer.

Test the boundary, not only the happy path

The useful test is not “does signing return a string?” It is “does a tiny change to the context make checkout refuse it?”

const context = {
  meta: { generatedAt: "2026-07-17T00:00:00.000Z" },
  birth: { timezone: "Europe/Madrid" },
} as DossierContext;

const signature = signContext(context);
expect(verifyContext(context, signature)).toBe(true);

const modified = structuredClone(context);
modified.birth.timezone = "Asia/Shanghai";
expect(verifyContext(modified, signature)).toBe(false);
Enter fullscreen mode Exit fullscreen mode

At minimum, add tests for failed consent, invalid email, price-tier changes, malformed payloads, webhook signature failures, and mismatched provider amounts. These are all places where a polished client can otherwise hide an incomplete backend boundary.

The reusable idea

When a browser needs a rich preview but a later paid operation depends on that preview, do not make the browser the authority. Have the server calculate the business-critical context, sign the exact payload it is willing to accept, verify it when the client returns, and persist the order intent before handing control to a payment provider.

Keep the rest of the security model separate: server-side pricing, verified payment events, scoped delivery credentials, expiration, rate limiting, and tests for changed inputs. The HMAC is a useful link in that chain, not the chain itself.

I work on AstroZen. This post describes an engineering pattern from the product; AstroZen's astrology features are for entertainment and personal reflection.

Top comments (0)