---
title: Metafield write using offline token context
description: >-
  Detects metafield writes (`metafieldsSet`) in offline-token contexts. Online
  tokens provide user attribution and expiry.
source_url:
  html: >-
    https://shopify.dev/docs/apps/build/security/app-security-checks/metafield-offline-token
  md: >-
    https://shopify.dev/docs/apps/build/security/app-security-checks/metafield-offline-token.md
---

# Metafield write using offline token context

Detects metafield writes (`metafieldsSet`) in offline-token contexts. Online tokens provide user attribution and expiry.

| Detail | Value |
| - | - |
| Check ID | `METAFIELD_OFFLINE_TOKEN` |
| Severity | High |
| How it runs | Agent review |

***

## Vulnerable example

## app/routes/app.products.$id.tag.tsx

```tsx
import type { ActionFunctionArgs } from "react-router";


import { unauthenticated } from "../shopify.server";


export const action = async ({
  request,
  params,
}: ActionFunctionArgs) => {
  const formData = await request.formData();
  const shop = String(formData.get("shop"));


  // An offline token writes with the app's standing authority:
  // the write carries no user attribution and outlives the user's
  // session.
  const { admin } = await unauthenticated.admin(shop);


  return admin.graphql(
    `#graphql
      mutation Tag($metafields: [MetafieldsSetInput!]!) {
        metafieldsSet(metafields: $metafields) {
          userErrors { message }
        }
      }`,
    {
      variables: {
        metafields: [
          {
            ownerId: `gid://shopify/Product/${params.id}`,
            namespace: "app",
            key: "tag",
            value: "reviewed",
            type: "single_line_text_field",
          },
        ],
      },
    },
  );
};
```

***

## How to fix

Use `authenticate.admin(request)` instead of `unauthenticated.admin()` for metafield writes.

Switching helpers isn't enough on its own: `shopifyApp` defaults `useOnlineTokens` to `false` and the app template leaves it off, so set `useOnlineTokens: true` as well. Without it, `authenticate.admin` still hands you the offline session and the write carries the app's standing authority rather than the user's.

For more detail, see [Online access tokens](https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens#online-access-tokens).

## app/routes/app.products.$id.tag.tsx

```tsx
import type { ActionFunctionArgs } from "react-router";


import { authenticate } from "../shopify.server";


export const action = async ({
  request,
  params,
}: ActionFunctionArgs) => {
  // Use the request-scoped Admin context, not a standing offline
  // one. For the write to carry user attribution and expire with
  // the session, shopifyApp also needs useOnlineTokens: true,
  // which the template leaves off by default. Keep
  // unauthenticated.admin for genuine background work such as
  // webhook processing.
  const { admin } = await authenticate.admin(request);


  return admin.graphql(
    `#graphql
      mutation Tag($metafields: [MetafieldsSetInput!]!) {
        metafieldsSet(metafields: $metafields) {
          userErrors { message }
        }
      }`,
    {
      variables: {
        metafields: [
          {
            ownerId: `gid://shopify/Product/${params.id}`,
            namespace: "app",
            key: "tag",
            value: "reviewed",
            type: "single_line_text_field",
          },
        ],
      },
    },
  );
};
```

***

## Related

* [`shopify app security check`](https://shopify.dev/docs/api/shopify-cli/app/app-security-check), the command that runs this check
* [All app security checks](https://shopify.dev/docs/apps/build/security/app-security-checks)

***
