Securing the order-status token
How to hand the order-status JWT off across Stripe's
confirmPayment redirect without leaking it into browser history,
the Referer header, or server logs.
What this is and why it matters
POST /api/v1/events/{eventId}/ticketing/checkout returns two values
you carry across the post-payment boundary:
orderId—ord_…ID of the new order. Public.orderStatusToken— short-lived (~10 min) bearer JWT that authorizesGET /api/v1/events/{eventId}/ticketing/orders/{orderId}/statusfor that specific order. A credential. Not as long-lived as a session token, but not public — anyone who reads it can poll the order for its validity window.
For redirect-required payment methods (3DS, BLIK, iDEAL, wallet
redirects), the consumer's page unloads during
stripe.confirmPayment. The natural handoff is to put both values
into Stripe's return_url as query parameters, then read them back
on the return page. That's what <CheckoutForm>'s
function-form returnUrl
makes ergonomic.
But carrying the JWT in a URL has three leak vectors:
- Browser history. Every navigation appends an entry; the URL
includes
?t=…. Anyone with local browser-history access (shared computers, browser extensions, sync to other devices) sees the token until the entry rolls out. Refererheader. Any outbound link from the return page ships the full return URL to the destination origin. Analytics pixels, embedded third-party widgets, ad networks, and user-clicked links all see the token.- Server access logs. The return page request itself is logged with the query string by every layer in front of it — CDN, edge gateway, application server, RUM telemetry, request tracers.
The token is short-lived and single-purpose (one orderId, one ~10-minute window), but it IS an access credential. Removing it from those channels is defense in depth on the credential.
The recommended pattern
Read the token off the URL once on mount, store it in client-only
state, replace the URL to drop the token from history, then feed
the in-memory token to useOrderStatus.
Note In Next.js 15 App Router, any component calling
useSearchParams()must be rendered inside a<Suspense>boundary or the route opts out of static rendering. The snippet below uses"use client"; wrap the component in<Suspense fallback={<p>Loading…</p>}>from its parent server component to satisfy the bailout.
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useOrderStatus } from "@feelrift/react";
export function OrderReturnPage({
eventId,
orderId,
}: {
eventId: string;
orderId: string;
}) {
const params = useSearchParams();
const router = useRouter();
// Capture the token from the URL on first render. The lazy
// initializer runs exactly once — subsequent renders keep the
// value even after the URL is cleaned up.
const [token] = useState(() => params.get("t") ?? undefined);
// Drop the token from the URL. `router.replace` rewrites the
// current history entry (no new entry, no back-button trail) and
// strips the param. Future outbound clicks ship a clean URL in
// the Referer header; future server logs of this page record the
// clean path.
useEffect(() => {
if (params.get("t")) {
router.replace(`/order/${orderId}`, { scroll: false });
}
// Intentionally only depends on orderId — re-running on every
// params change would loop. The cleanup happens exactly once.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [orderId]);
const { data, isTerminal } = useOrderStatus({ eventId, orderId, token });
if (!data) return <p>Loading…</p>;
return isTerminal ? <Receipt order={data} /> : <p>Finalizing…</p>;
}
What this pattern closes:
- Referer leakage — any subsequent outbound click ships the clean URL.
- Server access logs — every poll request goes through the bearer header, never the URL. Only the initial landing request records the token, and only on the consumer's own server (which the consumer controls).
- Future history entries — the cleaned URL is what subsequent navigations push onto the stack.
What this pattern doesn't close:
- The initial history entry from the redirect itself still contains
the token until
router.replaceruns. That's one entry, in one user's local browser, for one orderId, for ~10 minutes. Bounded.
Complementary mitigations
These are consumer-owned and stack with the pattern above. None require an SDK change.
Referrer-Policy header on the return route
The pattern above eliminates the token from the URL after first
render, but for the brief window before router.replace runs, an
outbound network request (analytics pixel, embedded widget) could
ship a Referer carrying the token. A Referrer-Policy response
header closes that window:
// Next.js App Router — app/order/[orderId]/layout.tsx (or middleware.ts)
export const metadata = {
/* … */
};
export const headers = () => [{ "Referrer-Policy": "no-referrer" }];
Or via middleware:
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
export function middleware(req: NextRequest) {
if (req.nextUrl.pathname.startsWith("/order/")) {
const res = NextResponse.next();
res.headers.set("Referrer-Policy", "no-referrer");
return res;
}
return NextResponse.next();
}
no-referrer is the strongest setting — it strips the Referer
entirely. strict-origin-when-cross-origin is the modern default
and still hides query strings on cross-origin navigations; pick it
if you need same-origin Referer for analytics.
Short token TTL
Already in place — the order-status JWT expires ~10 minutes after issuance. The SDK doesn't expose a knob to extend it because longer windows would compound the exposure here. If your post-purchase flow takes longer than ~10 minutes (rare), recover the order via the receipt-email link rather than re-using the token.
Alternatives considered
These are the options weighed against the recommended pattern.
URL fragment (#t=…)
Same idea as a query param, but the fragment never reaches the server — solves the access-log leak without any consumer code.
Why not: the fragment is JavaScript-only. Server-rendered return pages can't read it during SSR, so the same effect requires client-side hydration logic anyway. The query-param pattern above works in SSR, Pages Router, App Router, Astro, and Vite uniformly; the fragment locks you into client-only render.
Exchange the bearer for an httpOnly cookie
Server endpoint accepts the bearer, returns a Set-Cookie with the
JWT bound to the consumer's order route. SDK gets a helper that does
the exchange; useOrderStatus rides the cookie automatically. Same
pattern as OAuth code → session cookie.
Why not: cookies set by api.feelrift.com on a third-party
embed (organizer on bigfest.com) are third-party cookies. Safari
ITP blocks them by default. Chrome's third-party-cookie
deprecation is in progress (Partitioned Cookies / CHIPS is the
replacement with a different shape and still rolling out). The
pattern would work for Rift's own first-party apps and break for
third-party organizer embeds — exactly the consumers Principle VIII
says the platform must support equally. A primitive only some
consumers can use is no better than an escape hatch only some have.
If you DO control both the embed origin and the API origin (e.g.
you're building on top of @feelrift/react/client inside your own
Next.js app at pass.feelrift.com), see "Consumer-owned exchange
endpoint" below.
Encrypted query parameter
Encrypt the token with a server-held key before redirect, decrypt on the consumer's server when the return page renders.
Why not: opaque-but-still-in-URL. The encrypted blob inherits every URL leak vector — history, Referer, logs. The only win is that the leaked blob is useless to a passive observer, but a passive observer can also wait for the original token to expire in ~10 minutes. Real cost (key rotation, key management, decryption endpoint) for a marginal win.
Consumer-owned exchange endpoint
If your embed lives on the same eTLD+1 as your own application backend, the cleanest defense in depth is a consumer-side exchange: your backend accepts the bearer, mints a same-origin httpOnly cookie, and your frontend rides the cookie for subsequent polls.
Sketch (Next.js route handler):
// app/api/orders/handoff/route.ts
import { cookies } from "next/headers";
import { NextResponse, type NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const authHeader = req.headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
return new NextResponse(null, { status: 401 });
}
const token = authHeader.slice("Bearer ".length);
// Optionally: validate the JWT signature here using a published
// Rift public key if/when one is exposed. Today the token is
// opaque to consumers — the validation happens server-side at
// poll time. Storing without local validation is fine because
// an invalid token simply produces 401s on the next poll.
cookies().set("order_status", token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/order/",
maxAge: 600, // match JWT TTL
});
return new NextResponse(null, { status: 204 });
}
Then on the return page:
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useOrderStatus } from "@feelrift/react";
export function OrderReturnPage({
eventId,
orderId,
}: {
eventId: string;
orderId: string;
}) {
const params = useSearchParams();
const router = useRouter();
const [exchanged, setExchanged] = useState(false);
const [token, setToken] = useState<string | undefined>();
useEffect(() => {
const urlToken = params.get("t");
if (!urlToken) {
setExchanged(true); // nothing to exchange; cookie should already be set
return;
}
void fetch("/api/orders/handoff", {
method: "POST",
headers: { Authorization: `Bearer ${urlToken}` },
credentials: "include",
}).then(() => {
setToken(urlToken); // keep the token in memory for this session
router.replace(`/order/${orderId}`, { scroll: false });
setExchanged(true);
});
}, [params, router, orderId]);
// Poll once the exchange completes. Use the in-memory token so the
// first request is authoritative; the cookie covers reloads.
const { data, isTerminal } = useOrderStatus({
eventId,
orderId,
token: exchanged ? token : undefined,
});
if (!data) return <p>Loading…</p>;
return isTerminal ? <Receipt order={data} /> : <p>Finalizing…</p>;
}
The consumer's backend now holds the cookie binding — survives
reloads inside the polling window, doesn't depend on useRiftStore,
doesn't carry the token in any new URL after the first redirect.
The exchange endpoint is consumer-owned because the consumer knows
its own CORS posture, cookie domain, and security boundary; the
SDK doesn't.
When the simple pattern is enough
The plain read-once + router.replace pattern at the top of this
guide closes the meaningful leak vectors (Referer, server logs)
without any backend code. Most embeds should default to it. Move to
the consumer-owned exchange only when:
- Your post-purchase flow involves reloads inside the ~10-minute window (rare — the polling normally completes within seconds).
- Compliance review requires the credential never sit in URLs at all.
- You're operating a high-volume embed where the residual history entry materially raises your risk surface.
Related
<CheckoutForm>—returnUrl— the function-formreturnUrlthat putsorderId/orderStatusTokeninto the redirect URL in the first place.useOrderStatus— the polling hook that consumes the token, by header.<CheckoutForm>— Stateless return example — the canonical URL-encoded handoff.- MDN: Referrer-Policy