4: Session management

So far, your single-click app runs locally and supports installation and launch from a BigCommerce store control panel. However, the app currently doesn’t perform any authentication of the user for individual actions and page requests.

You can see this in action if you watch the Network tab in your browser dev tools while navigating within the app and paste one of the store-scoped URLs into a private browser window. The app will load, looking up the store’s API token without any verification.

In this exercise, you’ll implement your app’s own session cookie and two-tier authorization check to ensure every request comes from a verified session within the store control panel iframe.

Remember this checklist to ensure your app is ready to run in a single-click context:

  • The dev server must be running.
  • A remote tunnel must be serving your local port over a public HTTPS URL.
  • If using VS Code or GitHub Codespace, the forwarded port must be set to “Public” visibility.
  • The current remote tunnel URL must be set in APP_ORIGIN in .env.local and in all callback URLs in the Developer Portal.

Set a session secret

Set the following in .env.local (and .env.example) to prepare for this exercise, then restart the dev server.

1SESSION_SECRET=<session-secret>

Generate a value for SESSION_SECRET with the following command:

$openssl rand -base64 32

This secret is separate from your app’s client secret. Only your app knows this secret, and it will use it to sign and verify its own session tracking Json Web Token (JWT).

Implement session types and JWT signing

2

Implement JWT signing and verification

Replace the contents of src/lib/session/session-jwt.ts.

session-jwt.ts
import { jwtVerify, SignJWT } from "jose";
import { z } from "zod";
import { SessionPayload } from "@/lib/session/types";
const sessionPayloadSchema = z.object({
userId: z.number(),
authenticatedStores: z.array(z.string()),
issuedAt: z.number(),
});
const SESSION_TTL_SECONDS = 60 * 60;
export const SESSION_MAX_AGE_SECONDS = 10 * 60 * 60;
function getSessionSecret(): Uint8Array {
const secret = process.env.SESSION_SECRET;
if (!secret) {
throw new Error("SESSION_SECRET must be set to sign/verify session cookies.");
}
return new TextEncoder().encode(secret);
}
export async function signSession(payload: SessionPayload): Promise<string> {
return new SignJWT({ ...payload })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${SESSION_TTL_SECONDS}s`)
.sign(getSessionSecret());
}
export async function verifySession(jwt: string): Promise<SessionPayload> {
const { payload } = await jwtVerify(jwt, getSessionSecret(), { algorithms: ["HS256"] });
const session = sessionPayloadSchema.parse(payload);
if (Date.now() / 1000 - session.issuedAt > SESSION_MAX_AGE_SECONDS) {
throw new Error("Session exceeded its maximum age.");
}
return session;
}

jose is used for JWT handling. This logic handles initially signing a session JWT and verifying it on subsequent requests.

The TTL is deliberately short, because a stateless JWT can’t be revoked before it expires — there’s no server-side record to delete.

Example code

Example code

Implement the secondary authorization check

A user’s session is now being tracked, and you can have confidence that BigCommerce initially authenticated that user via the install or load callback. But the app isn’t yet performing any access check using this session.

You’ll be implementing a two-tier auth check. The first you’ll set up is the most thorough: This check verifies that a user session exists and includes the current store in its authenticated list, as well as confirming the store/user relationship in the credentials storage.

This full check ensures that, if a user’s access is revoked, this is reflected immediately rather than waiting for the session cookie to expire.

1

Implement the authorization check

A central utility function will handle the check.

Update src/lib/session/is-authorized-for-store.ts as shown.

is-authorized-for-store.ts
import { connection } from "next/server";
import { getDataMode, resolveApiToken } from "@/lib/bc-api-client/resolve-store-credentials";
import { getCredentialsStore } from "@/lib/credentials-store/get-credentials-store";
import { readSession, removeSessionStore } from "@/lib/session/session-cookie";
function isStoreUserLinked(storeHash: string, userId: number): Promise<boolean> {
return getCredentialsStore().isStoreUserLinked(storeHash, userId);
}
export const NOT_AUTHORIZED_FOR_STORE_MESSAGE = "Not authorized for this store.";
export async function isAuthorizedForStore(storeHash: string | undefined): Promise<boolean> {
await connection();
if (getDataMode() !== "MULTITENANT") {
return true;
}
if (!storeHash) {
return false;
}
const session = await readSession();
if (!session?.authenticatedStores.includes(storeHash)) {
return false;
}
const [isLinked, apiToken] = await Promise.all([
isStoreUserLinked(storeHash, session.userId),
resolveApiToken(storeHash),
]);
const isAuthorized = isLinked && Boolean(apiToken);
if (!isAuthorized) {
try {
await removeSessionStore(storeHash);
} catch {
// Not callable during a plain render; the next Server Action from
// this same stale session will retry the write.
}
}
return isAuthorized;
}

The check first verifies the information in the session cookie, then performs a DB lookup to verify a store/user record and valid API token.

Note the empty catch for removeSessionStore. Ideally, the app should proactively remove the session cookie if authorization failed, but Next.js won’t allow this action when the check is performed from a simple page render.

2

Create the shared authorized-page wrapper

For page renders, a centralized wrapper component will handle the authorization check and redirect the user if it fails.

Replace the contents of src/components/layout/authorized-page.tsx.

authorized-page.tsx
import { redirect } from "next/navigation";
import { isAuthorizedForStore } from "@/lib/session/is-authorized-for-store";
type PageProps = {
params: Promise<Record<string, string | string[] | undefined>>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export async function AuthorizedPage({
params,
searchParams,
pageComponent: PageComponent,
}: PageProps & { pageComponent: (props: PageProps) => React.ReactNode }) {
const resolvedParams = await params;
const storeHash = resolvedParams.storeHash;
const storeHashString = Array.isArray(storeHash) ? storeHash[0] : storeHash;
if (!(await isAuthorizedForStore(storeHashString))) {
redirect("/unauthorized");
}
return <PageComponent params={params} searchParams={searchParams} />;
}

Note that the component expects a prop with the page component to be rendered if authorization passes.

3

Protect the list page

The AuthorizedPage component now simply needs to be used to wrap the gift certificate list page.

Update src/app/store/[storeHash]/gift-certs/page.tsx.

gift-certs/page.tsx
import { GiftCertificatesPage } from "@/components/gift-certs-manager/gift-certificates/list/gift-certificates-page";
import { AuthorizedPage } from "@/components/layout/authorized-page";
export default function Page({
params,
searchParams,
}: {
params: Promise<Record<string, string | string[] | undefined>>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
return <AuthorizedPage params={params} searchParams={searchParams} pageComponent={GiftCertificatesPage} />;
}

It’s worth noting why the authorization wrapper is added to individual page routes rather than a common layout file. Layout file components are not re-rendered on client-side navigation, so a check placed there wouldn’t actually perform authentication on each page load.

4

Protect the detail page

Update src/app/store/[storeHash]/gift-certs/[id]/page.tsx.

gift-certs/[id]/page.tsx
import { GiftCertificateDetailPage } from "@/components/gift-certs-manager/gift-certificates/detail/gift-certificate-detail-page";
import { AuthorizedPage } from "@/components/layout/authorized-page";
export default function Page({
params,
searchParams,
}: {
params: Promise<Record<string, string | string[] | undefined>>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
return <AuthorizedPage params={params} searchParams={searchParams} pageComponent={GiftCertificateDetailPage} />;
}
5

Protect server actions

Rather than making use of a wrapper component, the server actions responsible for updating gift certificates need to be updated to perform the authorization check explicitly.

Update src/app/store/[storeHash]/gift-certs/[id]/actions.ts.

gift-certs/[id]/actions.ts
...
import { isAuthorizedForStore, NOT_AUTHORIZED_FOR_STORE_MESSAGE } from "@/lib/session/is-authorized-for-store";
...
export async function updateGiftCertificateStatus(
...
): Promise<ActionResult> {
if (!(await isAuthorizedForStore(storeHash))) {
return { success: false, message: NOT_AUTHORIZED_FOR_STORE_MESSAGE };
}
...
}
...
export async function refillGiftCertificateBalance(
...
): Promise<ActionResult> {
if (!(await isAuthorizedForStore(storeHash))) {
return { success: false, message: NOT_AUTHORIZED_FOR_STORE_MESSAGE };
}
...
}

Example code

Implement the primary authorization gate

The authorization check you’ve implemented is exhaustive, but the app benefits from having a faster, more optimistic check in the layer designed for capturing requests before they are resolved to routes: the Next.js proxy.

The proxy intercepts every request matching a given pattern and can short-circuit that request before further routing. The proxy is meant to run at the edge, where resources like the credentials store would not be available. But it’s the perfect place for a lightweight check of the session cookie, which will catch the majority of unauthorized requests.

1

Implement the proxy authorization gate

Replace the contents of src/proxy.ts.

proxy.ts
import { NextRequest, NextResponse } from "next/server";
import { getDataMode } from "@/lib/bc-api-client/data-mode";
import { signSession, verifySession } from "@/lib/session/session-jwt";
import { SESSION_COOKIE_NAME, SESSION_COOKIE_OPTIONS } from "@/lib/session/types";
import { getAbsoluteAppUrl } from "./lib/routing/app-url";
function redirectToUnauthorized(): NextResponse {
return NextResponse.redirect(getAbsoluteAppUrl(undefined, "/unauthorized"));
}
export async function proxy(request: NextRequest): Promise<NextResponse> {
if (getDataMode() !== "MULTITENANT") {
return NextResponse.next();
}
const storeHash = request.nextUrl.pathname.split("/")[2];
const sessionCookie = request.cookies.get(SESSION_COOKIE_NAME)?.value;
if (!storeHash || !sessionCookie) {
return redirectToUnauthorized();
}
let session: Awaited<ReturnType<typeof verifySession>>;
try {
session = await verifySession(sessionCookie);
} catch {
return redirectToUnauthorized();
}
if (!session.authenticatedStores.includes(storeHash)) {
return redirectToUnauthorized();
}
const response = NextResponse.next();
const refreshedJwt = await signSession(session);
response.cookies.set(SESSION_COOKIE_NAME, refreshedJwt, SESSION_COOKIE_OPTIONS);
return response;
}
export const config = {
matcher: ["/store/:storeHash{/:path}*"],
};

The proxy matches any /store/<store-hash>/... URL, verifies the session JWT, and confirms that the store hash is included in the user’s authenticated list.

On success, proxy.ts also re-signs the cookie with a fresh TTL. This is what makes the session’s effective lifetime “since last request” rather than “since login” — necessary because BigCommerce can only mint a fresh session by calling /load, and this app has no way to trigger /load from inside its own iframe. The refresh preserves the session’s original issuedAt unchanged, so SESSION_MAX_AGE_SECONDS (defined earlier in this exercise) still bounds the total session lifetime no matter how continuously the session is used.

Together, the two tiers you’ve built mean a request has to clear a cheap signature-and-claim check first, and then a database-backed confirmation second, before any protected content renders or any server action runs.

Reinstall or relaunch the app in the store control panel, and confirm pages still render normally for an authorized user.

If every page comes back unauthorized, the most common cause is the session cookie not surviving the iframe. Double-check that your tunnel is serving HTTPS and that APP_ORIGIN in .env.local matches it exactly — the cookie’s SameSite=None; Secure; Partitioned attributes will silently fail to persist otherwise.

Nothing else has changed about the app. If you previously identified the unauthenticated behavior by copying a URL from the iframe into a private browser window, you can try this action again to confirm the app now prevents unauthorized access.

Example code

Full step code

Full diff

Next: Add a real database