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

# 3: Single-click authentication

> Switch your app into MULTITENANT mode, register it in the BigCommerce Developer Portal, expose your dev server over a public tunnel, and implement the install and launch callbacks that back a real single-click app.

In this exercise, you move into "MULTITENANT" mode, which enables the full single-click app flow. In this production-intended mode, every request is scoped to a store through the `/store/[storeHash]` route segment, and each store's access token is obtained during install and looked up per request from durable storage instead of one hard-coded value.

Because the aim of this exercise is to support the full single-click app flow, there a few added layers you'll be dealing with:

* A remote tunnel must be used to expose your local app over a public HTTPS URL.
* Your app must be registered in the BigCommerce Developer Portal.
* A storage layer must be implemented to save store API tokens and user information. You'll be using a simple SQLite data file.

See the [project repository documentation](https://github.com/bigcommerce-edu/starter-sc-app-next/blob/main/docs/LOCAL-SINGLE-CLICK-APP.md) for more details on the setup process.

## Expose localhost over HTTPS

Make sure you're still running your local development server with `pnpm dev`.

BigCommerce needs a public, fully-qualified HTTPS URL for both the server-to-server callbacks and the control panel iframe. The following are three good options:

* **[VS Code port forwarding](https://code.visualstudio.com/docs/debugtest/port-forwarding)** — Built into VS Code and free with a GitHub account.
* **GitHub Codespaces** — For this option, push your code into your own GitHub repository and use the "Create codespace" feature within the Code dropdown to start a remote environment. You'll need to follow the project's initial setup (installing dependencies, setting up `.env.local`, and running `pnpm run dev`) in the remote environment. [Forwarding a port](https://docs.github.com/en/codespaces/developing-in-a-codespace/forwarding-ports-in-your-codespace)
  in a codespace works the same as in VS Code.
* **[ngrok](https://ngrok.com/)** — A dedicated tunneling tool. A free account is required for a usable session length.

The [project repository documentation](https://github.com/bigcommerce-edu/starter-sc-app-next/blob/main/docs/LOCAL-SINGLE-CLICK-APP.md) includes steps for each of these options.

If VS Code or Cursor is your IDE of choice, the port forwarding option has the benefit of using a consistent remote URL each time you start the tunnel. The prerequisites for utilizing this option include:

* [VS Code](https://code.visualstudio.com/) or [Cursor](https://www.cursor.com/) as your IDE
* A [GitHub account](https://github.com/)

In VS Code or Cursor, navigate to the **Ports** view and "Forward a Port".

![VS Code port forwarding](/_fern-img/7b3a458f1b94e3f5db92293040b218eaf53d0bb38e3c33ec007a1d18f37b54d4.webp)

If you haven't connected VS Code with GitHub before, you'll be prompted to sign in. Enter the port to forward (3000, or the port in your dev server output).

As a critical final step, you *must* set the forwarded port's visibility to **Public**! Right-click on the forwarded port and select "Port Visibility" -> "Public".

![VS Code port visibility](/_fern-img/324ded5e824293604f015a541d3871a6ada1449e90d93c661a522628d6ef076f.webp)

Once you have one of the remote tunnel options running, browse to the remote URL to confirm your app is successfully served. (In VS Code, click the globe icon on the forwarded port.)

> **Note**
>
> If you're using ngrok, a new URL will be generated every time you restart the tunnel. When the URL changes, make sure to update the value in two places described in the following exercise:
>
> * `APP_ORIGIN` in `.env.local` (restart the dev server)
> * The callback URLs in the Developer Portal

Make sure to capture the public HTTPS base URL for use in the following steps.

## Register the app in the Developer Portal

App registration happens in the [Developer Portal](https://build.bigcommerce.com/). Creating an account there is free and doesn't require a partnership — a partnership is only needed to publish to the marketplace. See [Beginning development](/developer/docs/integrations/apps/guide/beginning-development) for the broader prerequisites and [Managing apps in Dev Portal](/developer/docs/integrations/apps/guide/managing-apps-in-dev-portal) for a tour of the app profile. You'll also need a store whose control panel you can sign into as a user with permission to install apps.

> **Info**
>
> Make sure the sign into the Developer Portal using the same account that has owner permissions for your sandbox store.

1. Use the "Create New" button in the Developer Portal.
2. Copy the **Client ID** and **Client Secret** for use in the next step.
3. On the **Technical** tab of your new app, set the callback URLs, substituting your tunnel URL for `<APP_ORIGIN>`:

   | Callback    | URL                                |
   | ----------- | ---------------------------------- |
   | Auth        | `<APP_ORIGIN>/api/app/auth`        |
   | Load        | `<APP_ORIGIN>/api/app/load`        |
   | Uninstall   | `<APP_ORIGIN>/api/app/uninstall`   |
   | Remove User | `<APP_ORIGIN>/api/app/remove_user` |

   These map to the four route handlers under `src/app/api/app/`. The Auth callback URL must match `APP_ORIGIN` exactly — the app sends it back to BigCommerce as the OAuth `redirect_uri` during the token exchange, and BigCommerce rejects a mismatch.

![Callback URLs in the Developer Portal](/_fern-img/f6c282bec908326fd6fc5b4bb7b3b74afe09787dc0a5161d22f5d8b5724afc7c.webp)

> **Info**
>
> Note that you'll only be implementing the Auth and Load callbacks in this exercise. The full example app implements all four, and you'll have a chance
> to examine the implementations of Uninstall and Remove User at the end of the tutorial.

4. On the **Scopes** tab, enable:

   * **Customers**: modify
   * **Marketing**: modify
   * **Channel Settings**: read-only
   * **Channel Listings**: read-only
   * **Information & Settings**: read-only
   * **App Extensions**: manage

Keep the app in draft status. A draft app is installable from the control panel of any store your Developer Portal account owns, which is all you need for this tutorial.

## Set environment variables

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

```dotenv
DATA_MODE=MULTITENANT

BIGCOMMERCE_CLIENT_ID=<your-app-client-id>
BIGCOMMERCE_CLIENT_SECRET=<your-app-client-secret>
APP_ORIGIN=<your-public-https-tunnel-url>
CREDENTIALS_ENCRYPTION_KEY=<encryption-key>
CREDENTIALS_STORE_DRIVER=SQLITE
```

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

```shell
openssl rand -base64 32
```

`CREDENTIALS_ENCRYPTION_KEY` encrypts stored store access tokens at rest.

> **Warning**
>
> If you change `CREDENTIALS_ENCRYPTION_KEY` after storing tokens, previously stored tokens can no longer be decrypted.

> **Info**
>
> After changing `DATA_MODE` to `MULTITENANT`, when browsing to the root URL of your app, you'll now notice an "Unauthorized" error. In this
> production mode, only `/store/<store-hash>/...` URLs are allowed. You'll no longer be browsing to your app directly.

Now that your app is running through a remote tunnel and you've configured your app in the Developer Portal, you're ready to build the appropriate
authentication flow to embed a single-click app in the BigCommerce control panel.

## Build the SQLite credentials store driver

The first thing you'll need is a place to store the API tokens negotiated during the single-click install process. The initial storage implementation
in this app will be via SQLite; configuring an external database won't be required.

### Ignore the SQLite file

The file `data/credentials.sqlite` will be created the first time your app writes to SQLite. These data files shouldn't be tracked in your project's version control.

**Update** `.gitignore` to exclude `*.sqlite` files.

**`.gitignore`**

```bash showLineNumbers={false} title=".gitignore"
# local sqlite credentials store
/data/*.sqlite*
...
```

### Define the credentials store schema

**Replace** the contents of `src/lib/credentials-store/sqlite-driver/schema.ts`.

**`sqlite-driver/schema.ts`**

```js showLineNumbers={false} title="sqlite-driver/schema.ts"
export const CREATE_CREDENTIALS_STORE_SCHEMA = `
  CREATE TABLE IF NOT EXISTS stores (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    store_hash TEXT NOT NULL UNIQUE,
    access_token TEXT NOT NULL,
    scope TEXT NOT NULL,
    admin_user_id INTEGER NOT NULL
  );

  CREATE TABLE IF NOT EXISTS users (
    user_id INTEGER PRIMARY KEY,
    email TEXT NOT NULL
  );

  CREATE TABLE IF NOT EXISTS store_users (
    store_hash TEXT NOT NULL,
    user_id INTEGER NOT NULL,
    PRIMARY KEY (store_hash, user_id)
  );
`;
```

This schema defines tables for information about stores where the app is installed, users that have logged into the app, and the store/user relationship.
The schema will be run whenever the app connects to SQLite.

### Build the SQLite driver implementation

This is the most verbose step in this exercise, handling the grunt work of the database reads and writes.

**Replace** the contents of `src/lib/credentials-store/sqlite-driver/sqlite-credentials-store.ts`.

**`sqlite-credentials-store.ts`**

```js showLineNumbers={false} title="sqlite-credentials-store.ts"
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { CREATE_CREDENTIALS_STORE_SCHEMA } from "@/lib/credentials-store/sqlite-driver/schema";
import { decrypt, encrypt } from "@/lib/credentials-store/encryption";
import { CredentialsStore, StoreRecord, StoreUserRecord, UserRecord } from "@/lib/credentials-store/types";
import { AppError } from "@/lib/errors/app-error";
import { logError } from "@/lib/errors/logger";

function withDatabaseErrorHandling<T>(context: string, run: () => T): T {
  try {
    return run();
  } catch (error) {
    logError(`SqliteCredentialsStore: ${context}`, error);
    throw new AppError("DATABASE", "A database error occurred.", { cause: error });
  }
}

const DEFAULT_DB_PATH = "./data/credentials.sqlite";

function getDbPath(): string {
  return process.env.CREDENTIALS_SQLITE_PATH ?? DEFAULT_DB_PATH;
}

function openDatabase(path: string): DatabaseSync {
  mkdirSync(dirname(path), { recursive: true });

  const db = new DatabaseSync(path);

  db.exec(CREATE_CREDENTIALS_STORE_SCHEMA);

  return db;
}

interface StoreTokenRow {
  access_token: string;
}

interface UserIdRow {
  user_id: number;
}

interface CountRow {
  c: number;
}

interface ExistsRow {
  found: number;
}

export class SqliteCredentialsStore implements CredentialsStore {
  private readonly db: DatabaseSync;

  constructor(path: string = getDbPath()) {
    this.db = withDatabaseErrorHandling("open", () => openDatabase(path));
  }

  async setStore(store: StoreRecord): Promise<void> {
    withDatabaseErrorHandling("setStore", () => {
      this.db
        .prepare(
          `INSERT INTO stores (store_hash, access_token, scope, admin_user_id)
           VALUES (?, ?, ?, ?)
           ON CONFLICT(store_hash) DO UPDATE SET
             access_token = excluded.access_token,
             scope = excluded.scope,
             admin_user_id = excluded.admin_user_id`,
        )
        .run(store.storeHash, encrypt(store.accessToken), store.scope, store.adminUserId);
    });
  }

  async setUser(user: UserRecord): Promise<void> {
    withDatabaseErrorHandling("setUser", () => {
      this.db
        .prepare(
          `INSERT INTO users (user_id, email)
           VALUES (?, ?)
           ON CONFLICT(user_id) DO UPDATE SET
             email = excluded.email`,
        )
        .run(user.userId, user.email);
    });
  }

  async setStoreUser(storeUser: StoreUserRecord): Promise<void> {
    withDatabaseErrorHandling("setStoreUser", () => {
      this.db
        .prepare(
          `INSERT INTO store_users (store_hash, user_id)
           VALUES (?, ?)
           ON CONFLICT(store_hash, user_id) DO NOTHING`,
        )
        .run(storeUser.storeHash, storeUser.userId);
    });
  }

  async getStoreToken(storeHash: string): Promise<string | undefined> {
    return withDatabaseErrorHandling("getStoreToken", () => {
      const row = this.db.prepare("SELECT access_token FROM stores WHERE store_hash = ?").get(storeHash) as unknown as
        | StoreTokenRow
        | undefined;

      return row ? decrypt(row.access_token) : undefined;
    });
  }

  async isStoreUserLinked(storeHash: string, userId: number): Promise<boolean> {
    return withDatabaseErrorHandling("isStoreUserLinked", () => {
      const row = this.db
        .prepare("SELECT 1 as found FROM store_users WHERE store_hash = ? AND user_id = ?")
        .get(storeHash, userId) as unknown as ExistsRow | undefined;

      return row !== undefined;
    });
  }

  async deleteStore(storeHash: string): Promise<void> {
    withDatabaseErrorHandling("deleteStore", () => {
      this.db.exec("BEGIN TRANSACTION");

      try {
        const affectedUserIds = (
          this.db.prepare("SELECT user_id FROM store_users WHERE store_hash = ?").all(storeHash) as unknown as UserIdRow[]
        ).map((row) => row.user_id);

        this.db.prepare("DELETE FROM store_users WHERE store_hash = ?").run(storeHash);
        this.db.prepare("DELETE FROM stores WHERE store_hash = ?").run(storeHash);

        const countRemainingStoreUsersStmt = this.db.prepare("SELECT COUNT(*) as c FROM store_users WHERE user_id = ?");
        const deleteUserStmt = this.db.prepare("DELETE FROM users WHERE user_id = ?");

        for (const userId of affectedUserIds) {
          const { c } = countRemainingStoreUsersStmt.get(userId) as unknown as CountRow;

          if (c === 0) {
            deleteUserStmt.run(userId);
          }
        }

        this.db.exec("COMMIT");
      } catch (error) {
        this.db.exec("ROLLBACK");
        throw error;
      }
    });
  }

  async deleteUser(storeHash: string, userId: number): Promise<void> {
    withDatabaseErrorHandling("deleteUser", () => {
      this.db.exec("BEGIN TRANSACTION");

      try {
        this.db.prepare("DELETE FROM store_users WHERE store_hash = ? AND user_id = ?").run(storeHash, userId);

        const { c } = this.db.prepare("SELECT COUNT(*) as c FROM store_users WHERE user_id = ?").get(userId) as unknown as CountRow;

        if (c === 0) {
          this.db.prepare("DELETE FROM users WHERE user_id = ?").run(userId);
        }

        this.db.exec("COMMIT");
      } catch (error) {
        this.db.exec("ROLLBACK");
        throw error;
      }
    });
  }
}
```

This driver is a local-development choice only. `node:sqlite` gives synchronous, in-process access to one file on disk, which can't be shared across the multiple instances a real deployment runs. Every method still returns a `Promise` to satisfy the shared `CredentialsStore` interface, even though the work underneath happens synchronously.

The `SqliteCredentialsStore` class is instantiated, with the database connection being opened and schema being created in the constructor. Note the class's support not just for
upserting (`ON CONFLICT ... DO UPDATE`) into the various tables, but also for fetching a store's token, checking if a user exists on a store, and deleting all records related to a store.

### Create the credentials store accessor

Similar to the controller function that selects the appropriate API client, you'll now build out a function to select the credentials storage
driver (based on the `CREDENTIALS_STORE_DRIVER` environment variable).

**Replace** the contents of `src/lib/credentials-store/get-credentials-store.ts`.

**`get-credentials-store.ts`**

```js showLineNumbers={false} title="get-credentials-store.ts"
import { SqliteCredentialsStore } from "@/lib/credentials-store/sqlite-driver/sqlite-credentials-store";
import { CredentialsStore, CredentialsStoreDriver } from "@/lib/credentials-store/types";

const VALID_DRIVERS: CredentialsStoreDriver[] = ["SQLITE"];
const DEFAULT_DRIVER: CredentialsStoreDriver = "SQLITE";

function getConfiguredDriver(): CredentialsStoreDriver {
  const configuredDriver = process.env.CREDENTIALS_STORE_DRIVER?.toUpperCase();

  return VALID_DRIVERS.includes(configuredDriver as CredentialsStoreDriver)
    ? (configuredDriver as CredentialsStoreDriver)
    : DEFAULT_DRIVER;
}

function getConfiguredCredentialsStore(): CredentialsStore {
  switch (getConfiguredDriver()) {
    case "SQLITE":
    default:
      return new SqliteCredentialsStore();
  }
}

export function getCredentialsStore(): CredentialsStore {
  return getConfiguredCredentialsStore();
}
```

### Look up the store token through the credentials store

Recall the function `resolveApiToken`, used by the API client controller. Currently, this supports only "MOCK" and "STATIC" modes. Now that you've
implemented storage for API tokens, you can update this function to support looking up a token based on the store hash.

**Update** `src/lib/bc-api-client/resolve-store-credentials.ts`.

**`resolve-store-credentials.ts`**

```js {1,9-13} showLineNumbers={false} title="resolve-store-credentials.ts"
import { getCredentialsStore } from "@/lib/credentials-store/get-credentials-store";
import { getDataMode } from "@/lib/bc-api-client/data-mode";
...
export async function resolveApiToken(storeHash: string | undefined): Promise<string | undefined> {
  if (getDataMode() === "STATIC") {
    return process.env.STATIC_STORE_TOKEN;
  }

  if (!storeHash) {
    return undefined;
  }

  return getCredentialsStore().getStoreToken(storeHash);
}
```

The critical storage requirements are now handled in the application, and you're ready to implement the single-click authentication flow.

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

## Implement the install callback

When registering your app in the Developer Portal, you set the install callback URL to `/api/app/auth`. This is the route that BigCommerce will call
when a user initiates a request to install the app. In this step, you'll implement this endpoint.

### Implement the OAuth token exchange

BigCommerce sends a payload including an OAuth code to the install callback URL in your app. The critical function your callback must perform
is to make a request to BigCommerce to exchange this code for a permanent, store-scoped API token. Implement a utility function for performing this
exchange.

**Replace** the contents of `src/lib/bc-auth/exchange-code-for-token.ts`.

**`exchange-code-for-token.ts`**

```js showLineNumbers={false} title="exchange-code-for-token.ts"
import { z } from "zod";
import { TokenExchangeFailedError } from "@/lib/bc-auth/errors";

const BC_LOGIN_URL = "https://login.bigcommerce.com";

const tokenResponseSchema = z.object({
  access_token: z.string(),
  scope: z.string(),
  user: z.object({
    id: z.number(),
    username: z.string(),
    email: z.string(),
  }),
  context: z.string(),
  account_uuid: z.string(),
});

export type TokenResponse = z.infer<typeof tokenResponseSchema>;

export interface ExchangeCodeParams {
  code: string;
  context: string;
  scope: string;
  redirectUri: string;
}

export async function exchangeCodeForToken(params: ExchangeCodeParams): Promise<TokenResponse> {
  const clientId = process.env.BIGCOMMERCE_CLIENT_ID;
  const clientSecret = process.env.BIGCOMMERCE_CLIENT_SECRET;

  if (!clientId || !clientSecret) {
    throw new Error("BIGCOMMERCE_CLIENT_ID and BIGCOMMERCE_CLIENT_SECRET must be set to exchange an auth code.");
  }

  let response: Response;

  try {
    response = await fetch(`${BC_LOGIN_URL}/oauth2/token`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Accept: "application/json",
      },
      body: JSON.stringify({
        client_id: clientId,
        client_secret: clientSecret,
        code: params.code,
        context: params.context,
        scope: params.scope,
        grant_type: "authorization_code",
        redirect_uri: params.redirectUri,
      }),
    });
  } catch (error) {
    throw new TokenExchangeFailedError({ cause: error });
  }

  if (!response.ok) {
    throw new TokenExchangeFailedError({
      cause: `BigCommerce token exchange failed with status ${response.status}: ${(await response.text()).slice(0, 500)}`,
    });
  }

  try {
    return tokenResponseSchema.parse(await response.json());
  } catch (error) {
    throw new TokenExchangeFailedError({ cause: error });
  }
}
```

Note the use of `zod`, a popular library that simplifies validation against a schema. This function utilizes it to ensure the token response
from BigCommerce is in the expected format.

### Implement the install action with storage

The next utility function will encapsulate the logic for installing the app on a store, including performing the token exchange and persisting
store and user records (including the API token) to storage.

**Replace** the contents of `src/lib/bc-auth/install-store.ts`.

**`install-store.ts`**

```js showLineNumbers={false} title="install-store.ts"
import { exchangeCodeForToken } from "@/lib/bc-auth/exchange-code-for-token";
import { InstallSaveFailedError } from "@/lib/bc-auth/errors";
import { parseStoreHash } from "@/lib/bc-auth/verify-signed-payload";
import { getCredentialsStore } from "@/lib/credentials-store/get-credentials-store";
import { logError } from "@/lib/errors/logger";

export interface InstallStoreParams {
  code: string;
  context: string;
  scope: string;
  redirectUri: string;
}

export interface InstallStoreResult {
  storeHash: string;
  accessToken: string;
}

export async function installStore(params: InstallStoreParams): Promise<InstallStoreResult> {
  const tokenResponse = await exchangeCodeForToken(params);
  const storeHash = parseStoreHash(tokenResponse.context);
  const credentialsStore = getCredentialsStore();

  try {
    await credentialsStore.setUser({
      userId: tokenResponse.user.id,
      email: tokenResponse.user.email,
    });
    await credentialsStore.setStore({
      storeHash,
      accessToken: tokenResponse.access_token,
      scope: tokenResponse.scope,
      adminUserId: tokenResponse.user.id,
    });
    await credentialsStore.setStoreUser({ storeHash, userId: tokenResponse.user.id });
  } catch (error) {
    logError(`installStore: store "${storeHash}"`, error);
    throw new InstallSaveFailedError({ cause: error });
  }

  return { storeHash, accessToken: tokenResponse.access_token };
}
```

The data expected in the `params` (`code`, `context`, `scope`, and `redirectUri`) aligns with the params BigCommerce includes in the auth payload.

The API token is the key piece of data being stored here, but the app also records information about the specific user. This is the foundation for
multi-user support, recommended for all single-click apps. Tracking per-user information serves two important purposes:

* The store/user record will be used for authentication. Removing this record when an admin revokes a user's access will ensure no stale browser sessions continue to allow access.
* The app may choose to implement granular permissions for specific actions, exposing an interface to manage each user's individual permissions.

### Wire up the install route handler

Now that the key logic is in place, implement the API route handler itself to handle the BigCommerce auth callback.

**Replace** the contents of `src/app/api/app/auth/route.ts`.

**`app/auth/route.ts`**

```js showLineNumbers={false} title="app/auth/route.ts"
import { NextRequest, NextResponse } from "next/server";
import { installStore } from "@/lib/bc-auth/install-store";
import { InstallSaveFailedError, TokenExchangeFailedError } from "@/lib/bc-auth/errors";
import { getAppErrorUrl } from "@/lib/bc-auth/app-error-reason";
import { getAbsoluteAppUrl } from "@/lib/routing/app-url";
import { logError } from "@/lib/errors/logger";

export async function GET(request: NextRequest): Promise<NextResponse> {
  const code = request.nextUrl.searchParams.get("code");
  const context = request.nextUrl.searchParams.get("context");
  const scope = request.nextUrl.searchParams.get("scope");

  if (!code || !context || !scope) {
    logError("GET /api/app/auth", new Error("code, context, and scope are required."));

    return NextResponse.redirect(getAbsoluteAppUrl(undefined, getAppErrorUrl("INSTALL_FAILED")));
  }

  let storeHash: string;

  try {
    ({ storeHash } = await installStore({
      code,
      context,
      scope,
      redirectUri: getAbsoluteAppUrl(undefined, "/api/app/auth"),
    }));
  } catch (error) {
    logError("GET /api/app/auth: installStore", error);

    if (error instanceof TokenExchangeFailedError) {
      return NextResponse.redirect(getAbsoluteAppUrl(undefined, getAppErrorUrl("TOKEN_EXCHANGE_FAILED")));
    }

    if (error instanceof InstallSaveFailedError) {
      return NextResponse.redirect(getAbsoluteAppUrl(undefined, getAppErrorUrl("INSTALL_SAVE_FAILED")));
    }

    return NextResponse.redirect(getAbsoluteAppUrl(undefined, getAppErrorUrl("INSTALL_FAILED")));
  }

  return NextResponse.redirect(getAbsoluteAppUrl(storeHash, "/"));
}
```

The API route is letting the previously created function handle the heavy lifting and takes care of redirecting the user to the home page of the
app after a successful install.

`getAbsoluteAppUrl` handles generating a fully qualified URL using the env var `APP_ORIGIN`. Note the use of the `storeHash` in the home page
redirect, resulting in a URL path like `/store/<store-hash>/`. All URLs generated within the app for actions and navigation will use this
URL structure.

With the install callback implemented, you're ready to try installing the app in your store control panel.

> **Warning**
>
> Another reminder: Make sure you have your dev server *and* remote tunnel running. If using VS Code or Git Codespace forwarding, you also *must*
> make sure your forwarded port has been set to "Public" visibility!

Log into your sandbox store using the same account that owns the app entry in the Developer Portal. Navigate to Apps -> Develop and choose your
test app to start the install process.

![Develop apps in store control panel](/_fern-img/3728f8d28d3a83d1f1320bafc0efe5ae8336a719159c95780bb9694a265541b6.webp)

![Install app](/_fern-img/6f1c9e84262073ce4876987eaba2d0269d930434ec1f6a054fd6f6ced4ffefce.webp)

After installing, you should see the same home page with your gift certificates list, now rendered directly in your store control panel.

![Gift certificates list in control panel](/_fern-img/6ef639a479f6250c06d4183e42183dae6edd1455fe5b20fda34bef0439517c73.webp)

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

## Implement the load callback

So far, only initial installation is handled by your app. Within the store control panel, if you navigate away from the app and then back (via its
entry under "Apps" in the left navbar), the app will fail to load. You must implement the load callback to handle each time a user navigates
to the app.

### Implement payload verification

BigCommerce includes a signed payload in each load request. First, implement a utility function to verify the payload is valid.

**Update** the contents of `src/lib/bc-auth/verify-signed-payload.ts` as shown.

**`verify-signed-payload.ts`**

```js {1-24,29-44} showLineNumbers={false} title="verify-signed-payload.ts"
import { jwtVerify } from "jose";
import { z } from "zod";

const signedPayloadSchema = z.object({
  sub: z.string(),
  user: z.object({
    id: z.number(),
    email: z.string(),
    locale: z.string().optional(),
  }),
  owner: z.object({
    id: z.number(),
    email: z.string(),
  }),
  url: z
    .string()
    .refine((url) => url.startsWith("/") && !url.startsWith("//"), {
      message: "url must be a root-relative path (not protocol-relative).",
    })
    .optional(),
});

export type SignedPayload = z.infer<typeof signedPayloadSchema>;

export function parseStoreHash(storesSlashHash: string): string {
  ...
}

export async function verifySignedPayload(signedPayloadJwt: string): Promise<SignedPayload> {
  const clientId = process.env.BIGCOMMERCE_CLIENT_ID;
  const clientSecret = process.env.BIGCOMMERCE_CLIENT_SECRET;

  if (!clientId || !clientSecret) {
    throw new Error("BIGCOMMERCE_CLIENT_ID and BIGCOMMERCE_CLIENT_SECRET must be set to verify a signed payload.");
  }

  const { payload } = await jwtVerify(signedPayloadJwt, new TextEncoder().encode(clientSecret), {
    algorithms: ["HS256"],
    issuer: "bc",
    audience: clientId,
  });

  return signedPayloadSchema.parse(payload);
}
```

`zod` is used once again to validate that the payload exhibits the expected shape.

BigCommerce delivers the payload as a Json Web Token (JWT) signed with the registered app's client secret. The verification logic uses `jwtVerify`
with the secret to confirm the payload originated from BigCommerce and hasn't been tampered with.

Note that the library `jose` is used for JWT handling. This library ensures the widest compatibility with different runtimes.

### Implement the load action

The job of the load action is to perform the payload verification and ensure a record exists for the user in credentials storage.

**Replace** the contents of `src/lib/bc-auth/load-store.ts`.

**`load-store.ts`**

```js showLineNumbers={false} title="load-store.ts"
import { StoreNotInstalledError } from "@/lib/bc-auth/errors";
import { parseStoreHash, verifySignedPayload } from "@/lib/bc-auth/verify-signed-payload";
import { getCredentialsStore } from "@/lib/credentials-store/get-credentials-store";

export interface LoadStoreResult {
  storeHash: string;
  url: string;
}

export async function loadStore(signedPayloadJwt: string): Promise<LoadStoreResult> {
  const payload = await verifySignedPayload(signedPayloadJwt);
  const storeHash = parseStoreHash(payload.sub);
  const credentialsStore = getCredentialsStore();
  const token = await credentialsStore.getStoreToken(storeHash);

  if (!token) {
    throw new StoreNotInstalledError(storeHash);
  }

  await credentialsStore.setUser({ userId: payload.user.id, email: payload.user.email });
  await credentialsStore.setStoreUser({ storeHash, userId: payload.user.id });

  return { storeHash, url: payload.url ?? "/" };
}
```

A key detail to note about the load action: No *store* record is written to storage. Each store's record is written, with its API token, only when the
app is first installed on that store. The only potentially new information to record each time the load callback is called is the individual
user who is accessing the app in this session.

### Wire up the launch route handler

Implement the API route handler itself.

**Replace** the contents of `src/app/api/app/load/route.ts`.

**`app/load/route.ts`**

```js showLineNumbers={false} title="app/load/route.ts"
import { NextRequest, NextResponse } from "next/server";
import { isSignedPayloadVerificationError, StoreNotInstalledError } from "@/lib/bc-auth/errors";
import { getAppErrorUrl } from "@/lib/bc-auth/app-error-reason";
import { loadStore } from "@/lib/bc-auth/load-store";
import { getAbsoluteAppUrl } from "@/lib/routing/app-url";
import { logError } from "@/lib/errors/logger";

export async function GET(request: NextRequest): Promise<NextResponse> {
  const signedPayloadJwt = request.nextUrl.searchParams.get("signed_payload_jwt");

  if (!signedPayloadJwt) {
    logError("GET /api/app/load", new Error("signed_payload_jwt is required."));

    return NextResponse.redirect(getAbsoluteAppUrl(undefined, getAppErrorUrl("LOAD_FAILED")));
  }

  let storeHash: string;
  let url: string;

  try {
    ({ storeHash, url } = await loadStore(signedPayloadJwt));
  } catch (error) {
    if (error instanceof StoreNotInstalledError) {
      return NextResponse.redirect(getAbsoluteAppUrl(undefined, getAppErrorUrl("NOT_INSTALLED")));
    }

    if (isSignedPayloadVerificationError(error)) {
      return NextResponse.redirect(getAbsoluteAppUrl(undefined, getAppErrorUrl("INVALID_SESSION")));
    }

    logError("GET /api/app/load", error);

    return NextResponse.redirect(getAbsoluteAppUrl(undefined, getAppErrorUrl("LOAD_FAILED")));
  }

  return NextResponse.redirect(getAbsoluteAppUrl(storeHash, url));
}
```

Return to your store control panel and try re-opening your app from the "Apps" menu.

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

## Troubleshooting

* **Nothing reaches your app.** The tunnel is down, its URL has changed, or its visibility is private. Load `<APP_ORIGIN>` directly in a browser to confirm.
* **A stale URL somewhere.** After any tunnel URL change, `APP_ORIGIN`, all four Developer Portal callback URLs, and a dev server restart all have to agree. It's also worth uninstalling and reinstalling the app, since the stored install is tied to the old origin.

See the full [Running Locally as a Single-Click App](https://github.com/bigcommerce-edu/starter-sc-app-next/blob/main/docs/LOCAL-SINGLE-CLICK-APP.md#troubleshooting) guide for more failure modes.

## Full step code

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

[Next: Session management](/developer/docs/integrations/apps/tutorial-single-click-app/4-session-management)