> This page is for Developer.

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.bigcommerce.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.bigcommerce.com/_mcp/server.

# 4: Session management

> Implement the app's own session cookie and its two-tier authorization check.

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.

> **Info**
>
> 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.

```dotenv
SESSION_SECRET=<session-secret>
```

Generate a value for `SESSION_SECRET` with the following command:

```shell
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

### Define the session payload and cookie config

**Replace** the contents of `src/lib/session/types.ts`.

**`session/types.ts`**

```js showLineNumbers={false} title="session/types.ts"
export interface SessionPayload {
  userId: number;
  authenticatedStores: string[];
  issuedAt: number;
}

export const SESSION_COOKIE_NAME = "bc_app_session";

export const SESSION_COOKIE_OPTIONS = {
  httpOnly: true,
  secure: true,
  sameSite: "none" as const,
  partitioned: true,
  path: "/",
};
```

This app mints its own session token: a short-lived, stateless JWT stored in an `httpOnly`, `SameSite=None; Secure; Partitioned` cookie. Those three attributes on the cookie aren't optional — they're required for the cookie to work at all inside the control panel's cross-origin iframe. The payload itself is `{ userId, authenticatedStores, issuedAt }`. Notice that `authenticatedStores` is a list, not a single store hash — that's what lets one admin be launched into multiple stores concurrently under a single cookie.

### Implement JWT signing and verification

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

**`session-jwt.ts`**

```js showLineNumbers={false} title="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](https://github.com/bigcommerce-edu/starter-sc-app-next/compare/e-session-01-pre...e-session-01-post?diff=split)

## Implement the session cookie

### Implement the cookie read/write functions

This step will establish utility functions for working with the session cookie itself.

**Replace** the contents of `src/lib/session/session-cookie.ts`.

**`session-cookie.ts`**

```js showLineNumbers={false} title="session-cookie.ts"
import { cookies } from "next/headers";
import { signSession, verifySession } from "@/lib/session/session-jwt";
import { SessionPayload, SESSION_COOKIE_NAME, SESSION_COOKIE_OPTIONS } from "@/lib/session/types";

export async function readSession(): Promise<SessionPayload | undefined> {
  const cookieStore = await cookies();
  const raw = cookieStore.get(SESSION_COOKIE_NAME)?.value;

  if (!raw) {
    return undefined;
  }

  try {
    return await verifySession(raw);
  } catch {
    return undefined;
  }
}

export async function upsertSessionStore(userId: number, storeHash: string): Promise<void> {
  const existing = await readSession();
  const isSameUser = existing && existing.userId === userId;
  const authenticatedStores = isSameUser
    ? Array.from(new Set([...existing.authenticatedStores, storeHash]))
    : [storeHash];
  const issuedAt = isSameUser ? existing.issuedAt : Math.floor(Date.now() / 1000);

  const jwt = await signSession({ userId, authenticatedStores, issuedAt });
  const cookieStore = await cookies();

  cookieStore.set(SESSION_COOKIE_NAME, jwt, SESSION_COOKIE_OPTIONS);
}

export async function removeSessionStore(storeHash: string): Promise<void> {
  const existing = await readSession();

  if (!existing || !existing.authenticatedStores.includes(storeHash)) {
    return;
  }

  const authenticatedStores = existing.authenticatedStores.filter((existingStoreHash) => existingStoreHash !== storeHash);
  const jwt = await signSession({ userId: existing.userId, authenticatedStores, issuedAt: existing.issuedAt });
  const cookieStore = await cookies();

  cookieStore.set(SESSION_COOKIE_NAME, jwt, SESSION_COOKIE_OPTIONS);
}

export async function clearSession(): Promise<void> {
  const cookieStore = await cookies();

  cookieStore.delete({ name: SESSION_COOKIE_NAME, ...SESSION_COOKIE_OPTIONS });
}
```

These basic functions handle reading, setting, and removing the session cookie, relying on the previously created utility functions for handling
the JWT value.

Note that `upsertSessionStore` handles setting the `issuedAt` value conditionally depending on whether a session cookie already exists. This value
is important for the logic that ultimately expires the session.

`clearSession` deletes the cookie outright, ending the session for every store it covered — unlike `removeSessionStore`, which drops a single stale
store claim. This is what the boilerplate's control panel syncing calls when BigCommerce reports that the admin logged out of the control panel.

### Set the session cookie during install

The install API endpoint now needs to be updated to initially set the session cookie.

**Update** `src/lib/bc-auth/install-store.ts`.

**`install-store.ts`**

```js {2,11-12} showLineNumbers={false} title="install-store.ts"
...
import { upsertSessionStore } from "@/lib/session/session-cookie";

...
export async function installStore(params: InstallStoreParams): Promise<InstallStoreResult> {
  ...

  try {
    ...
    await credentialsStore.setStoreUser({ storeHash, userId: tokenResponse.user.id });

    await upsertSessionStore(tokenResponse.user.id, storeHash);
  } catch (error) {
    ...
  }

  return ...
}
```

### Set the session cookie during load

**Update** `src/lib/bc-auth/load-store.ts`.

**`load-store.ts`**

```js {2,10} showLineNumbers={false} title="load-store.ts"
...
import { upsertSessionStore } from "@/lib/session/session-cookie";

...

export async function loadStore(signedPayloadJwt: string): Promise<LoadStoreResult> {
  ...
  await credentialsStore.setUser({ userId: payload.user.id, email: payload.user.email });
  await credentialsStore.setStoreUser({ storeHash, userId: payload.user.id });
  await upsertSessionStore(payload.user.id, storeHash);

  return ...
}
```

Now both installing or navigating to the app in the store control panel will result in a session cookie identifying the user and their
authenticated stores. The same user accessing the app from multiple stores will result in the session cookie being updated to expand the authenticated
stores list.

[Example code](https://github.com/bigcommerce-edu/starter-sc-app-next/compare/e-session-02-pre...e-session-02-post?diff=split)

## 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.

### 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`**

```js {1-9,12-46} showLineNumbers={false} title="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.

### 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`**

```jsx showLineNumbers={false} title="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.

### 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`**

```jsx {2,4-11} showLineNumbers={false} title="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} />;
}
```

> **Warning**
>
> 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.

### Protect the detail page

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

**`gift-certs/[id]/page.tsx`**

```jsx {2,4-11} showLineNumbers={false} title="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} />;
}
```

### 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`**

```js {2,8-11,20-23} showLineNumbers={false} title="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](https://github.com/bigcommerce-edu/starter-sc-app-next/compare/e-session-03-pre...e-session-03-post?diff=split)

## 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.

### Implement the proxy authorization gate

**Replace** the contents of `src/proxy.ts`.

**`proxy.ts`**

```js showLineNumbers={false} title="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.

> **Note**
>
> 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](https://github.com/bigcommerce-edu/starter-sc-app-next/compare/e-session-04-pre...e-session-04-post?diff=split)

## Full step code

[Full diff](https://github.com/bigcommerce-edu/starter-sc-app-next/compare/e-session-start...e-session-complete?diff=split)

[Next: Add a real database](/developer/docs/integrations/apps/tutorial-single-click-app/5-database)