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 |
Anchor to Vulnerable exampleVulnerable example
app/routes/app.products.$id.tag.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",
},
],
},
},
);
};
Anchor to How to fixHow 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.
app/routes/app.products.$id.tag.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",
},
],
},
},
);
};
shopify app security check, the command that runs this check- All app security checks
Was this page helpful?