Menu
Guides
Storefront Development

Storefront Development

Authentication in API extensions

Learn how to authenticate custom resolvers whose response belongs to one shopper, whether they call a VTEX API or a third-party service.

14 min read
A custom resolver is a server-side function that retrieves or updates data not provided by the FastStore API by default. For example, a resolver can retrieve a shopper's loyalty balance from a third-party service or add a derived field to a product.
Not every resolver requires authentication. A resolver that only derives a value from existing data or retrieves public information may not need the shopper's identity. However, when a resolver reads or modifies shopper-specific data, it must ensure that each shopper can access only their own information.
The authentication flow depends on the service being called:
  • For VTEX APIs, the resolver forwards the shopper's authentication cookie so that the API can authorize the request.
  • For third-party services, the resolver verifies the shopper's session and uses an identifier from that session to retrieve the correct data.
In both cases, the resolver must not trust identifiers provided by the client, such as an email address or user ID, because they can be modified.
This guide explains how to:
  • Determine whether a resolver requires shopper authentication.
  • Verify that a request comes from a signed-in shopper.
  • Retrieve the shopper's identity from the session.
  • Call VTEX APIs and third-party services using the appropriate authentication flow.
  • Prevent personal data exposure through shared caches, detailed error messages, and exposed credentials.
For instructions on declaring types and resolvers, see Extending API schemas. This guide assumes you are familiar with that structure.

Step 1: Determine whether your resolver needs authentication

Make sure you know which of the two credential models your resolver needs.
Fixed credentialsShopper credentials
ExamplesX-VTEX-API-AppKey and X-VTEX-API-AppToken, an integration Bearer token, a partner key in vtex.envVtexIdclientAutCookie_{account}
Caller identityThe storeThe signed-in shopper
PermissionsThe permissions the credential holds, usually broadWhat that one shopper may access
Enforced byYour resolver code onlyThe service you call
A fixed credential works as a service account: it is identical on every request, whoever is browsing. It is the right choice for data that is the same for every shopper, such as catalog information, store locations, promotions, and configuration. It carries no notion of the current shopper, so when a resolver uses one to read a personal record, your resolver code is the only thing preventing one shopper from accessing another shopper's data.
Three questions identify a resolver that needs attention:
  1. Does the response differ from one shopper to the next?
  2. Does the resolver create, change, or delete something that belongs to someone? Saving an address, cancelling a subscription, removing a record. Nothing is disclosed, but another shopper's data is altered, and the same rule applies.
  3. Does the call authenticate with a credential that is the same on every request?
When the answer to question 3 is yes, along with either of the first two, the resolver needs the steps below. This holds regardless of which service it calls, and regardless of whether the credential is a VTEX app key or a token issued by an external provider.

Resolvers that need authentication

Not every resolver calls an API. The example in Extending API schemas derives a field from data already loaded:
src/graphql/vtex/resolvers/product.ts

_10
const StoreProductResolver = {
_10
StoreProduct: {
_10
customData: (root: StoreProductRoot) => `My item id: ${root.itemId}`,
_10
},
_10
}

Nothing here reaches the network, so nothing here needs credentials. The same applies to Adding installment information in the product details page, which reshapes data already present in root.
Authentication becomes a concern the moment a resolver calls an API. That is the case this guide covers.

Understanding the resolver context

Custom resolvers receive the standard GraphQL signature. The third argument is the FastStore context:

_10
const MyResolver = {
_10
Query: {
_10
myField: async (root, args, ctx) => {
_10
ctx.headers // Incoming request headers, including `cookie`.
_10
ctx.account // Your VTEX account name.
_10
ctx.clients // Platform clients, such as `commerce` and `search`.
_10
ctx.storage // Per-request storage, such as locale and channel.
_10
},
_10
},
_10
}

ctx.headers.cookie holds the shopper's cookies, forwarded from the browser. Every pattern in this guide builds on it.

Public writes and personal data

Adding a contact form to a landing page documents a mutation that writes to Master Data with no credentials:

_10
const response = await fetch(
_10
'https://{account}.vtexcommercestable.com.br/api/dataentities/ContactForm/documents?_schema=contactForm',
_10
{
_10
method: 'POST',
_10
headers: { 'Content-Type': 'application/json' },
_10
body: JSON.stringify(input),
_10
}
_10
)

This is appropriate for that scenario: an anonymous visitor submits a form into a dedicated entity, and there is no shopper identity to carry. Even so, it is an unauthenticated write path, so you remain responsible for rate limiting, validating input against the schema, and keeping the entity's read ACL closed.
Never reuse this pattern for an entity that holds personal data, such as CL, NL, AD, or any custom entity keyed by email or document number. Adding appKey and appToken to it does not make it safer. It makes the request succeed against every record in the entity.
Follow the sections below instead.

Step 2: Require authentication in the resolver

The FastStore API marks its own private fields with the @auth directive, which validates the shopper's token before the resolver runs. That directive is applied to the platform schema, so do not assume it will be enforced on a field you declare. Write the check in your resolver:
src/graphql/vtex/utils/auth.ts

_24
import { UnauthorizedError } from '@faststore/api'
_24
_24
/**
_24
* Throws unless the incoming request carries a valid shopper token.
_24
* Call this first in every resolver that reads or writes personal data.
_24
*/
_24
export const requireAuthenticatedUser = async (ctx: any) => {
_24
try {
_24
const validation = await ctx.clients.commerce.vtexid.validate()
_24
_24
if (validation?.authStatus?.toLowerCase() !== 'success') {
_24
throw new UnauthorizedError('Authentication required')
_24
}
_24
} catch (error) {
_24
if (error instanceof UnauthorizedError) {
_24
throw error
_24
}
_24
_24
// For a missing or expired token, VTEX ID answers 401 and the client
_24
// throws instead of resolving. Without this catch, the caller would get
_24
// a 500 rather than a 401.
_24
throw new UnauthorizedError('Authentication required')
_24
}
_24
}

The try/catch is not optional. For a missing or expired token, VTEX ID answers 401 and the client throws rather than resolving with an unsuccessful status, so the check above would never run and the caller would receive a 500.
@faststore/api exports UnauthorizedError and ForbiddenError, which the GraphQL route converts into the corresponding HTTP statuses. Throwing them is preferable to returning null, which the storefront cannot distinguish from an empty result.

Step 3: Take identity from the session

Do not let the caller provide the identifier used to retrieve personal data. Even after signing in, a shopper could pass another shopper’s email, user ID, or document number and access that person’s record.
Avoid declaring an identifier argument:
src/graphql/vtex/typeDefs/profile.graphql

_10
extend type Query {
_10
customerProfile(email: String!): CustomerProfile
_10
}

src/graphql/vtex/resolvers/profile.ts

_10
customerProfile: async (_, { email }, ctx) => {
_10
await requireAuthenticatedUser(ctx)
_10
_10
return fetchProfileByEmail(email) // The caller chooses whose data to read.
_10
}

Derive the identity from the session instead:
src/graphql/vtex/typeDefs/profile.graphql

_10
extend type Query {
_10
customerProfile: CustomerProfile
_10
@cacheControl(scope: "private", sMaxAge: 0)
_10
}

src/graphql/vtex/resolvers/profile.ts

_12
customerProfile: async (_, __, ctx) => {
_12
await requireAuthenticatedUser(ctx)
_12
_12
const session = await ctx.clients.commerce.session('')
_12
const userId = session.namespaces?.authentication?.storeUserId?.value
_12
_12
if (!userId) {
_12
throw new UnauthorizedError('Authentication required')
_12
}
_12
_12
return fetchProfileByUserId(userId, ctx)
_12
}

Remove the argument from the type definition as well as from the resolver. An argument that exists will eventually be used.

Read the identity from the right namespace

Use authentication.storeUserId, as above. VTEX documents it as extracted from VTEX ID cookie validation, so it holds a value only for a shopper who signed in. Do not use profile.id: it is loaded from Master Data and can hold a value for a visitor who was identified without signing in.
Treat an empty value as an error rather than as an absent result. Every session field is optional, so optional chaining yields undefined without complaining, and if that reaches your lookup the filter may drop out of the request and the call returns every record. Throw UnauthorizedError before the identifier is used, as in the example above.
The remaining session identifiers answer other questions. unitId is the B2B organization unit, and customerId is read by the platform while resolving contracts, so neither identifies a person.

Step 4: Authenticate as the shopper

Step 3 gave you the shopper's identity. Now you need to prove it to whichever service you call, and how you do that depends on what's on the other end.
VTEX APIs can accept the shopper's own cookie and enforce that shopper's permissions themselves. Third-party APIs have no notion of that cookie, so your resolver has to authenticate as the integration and filter the data itself, using the identity from Step 3.
Many VTEX APIs accept the shopper's own cookie and answer as that shopper, so the service performs the authorization instead of your resolver. Forward the incoming cookie header as it arrives:
src/graphql/vtex/resolvers/orderSummary.ts

_10
const response = await fetch(
_10
`https://${ctx.account}.vtexcommercestable.com.br/api/oms/user/orders?per_page=1`,
_10
{
_10
headers: {
_10
'content-type': 'application/json',
_10
// Forwarding the shopper's cookies is what scopes the response.
_10
cookie: ctx.headers?.cookie ?? '',
_10
},
_10
}
_10
)

/api/oms/user/orders returns only the orders belonging to whoever is signed in. Sending an appKey and appToken pair instead would authenticate the request as the store, and the endpoint would no longer be scoped to one person.
Forwarding the whole cookie header is the simplest form and the one the platform itself uses. When an endpoint needs the token on its own, read it with a parser rather than by splitting the string:

_10
import { parse } from 'cookie'
_10
_10
const token = parse(ctx.headers?.cookie ?? '')[
_10
`VtexIdclientAutCookie_${ctx.account}`
_10
]

Use a parser such as the cookie package rather than split('=')[1]. Token values can contain =, and truncating one causes a silent 401 response.
Writes follow the same rule as reads. Resolve the record's identifier within the same request, from the session or from a scoped read. An identifier accepted as an argument returns control over which record is written to the caller.
Not every VTEX endpoint is scoped by the shopper. Check the endpoint's reference before relying on the cookie alone, and treat an endpoint that answers the same for every session as the third-party case below.

Third-party APIs: derive the identifier server-side

An external system, such as an ERP, a CRM, or a loyalty provider, knows nothing about the VTEX cookie, so there is nothing to forward. The integration credential stays on the server, and the shopper's identifier comes from the session:
src/graphql/thirdParty/resolvers/loyalty.ts

_21
loyaltyBalance: async (_: never, __: never, ctx: any) => {
_21
await requireAuthenticatedUser(ctx)
_21
_21
const session = await ctx.clients.commerce.session('')
_21
const shopperId = session.namespaces?.authentication?.storeUserId?.value
_21
_21
if (!shopperId) {
_21
throw new UnauthorizedError('Authentication required')
_21
}
_21
_21
const response = await fetch(
_21
`${process.env.LOYALTY_API_URL}/customers/${shopperId}/balance`,
_21
{
_21
headers: {
_21
Authorization: `Bearer ${process.env.LOYALTY_API_TOKEN}`,
_21
},
_21
}
_21
)
_21
_21
// Handle the response.
_21
}

In this arrangement your resolver is the only barrier, because the external service authorizes the integration rather than the shopper. The identifier must come from the session, as above, and never from a query argument. Return only the records that belong to the shopper who asked.

Step 5: Return only the fields the interface uses

Request the fields your interface renders, not the whole record. This limits the impact when a service returns more than expected, and it keeps personal data out of logs and traces. When the endpoint supports selecting fields, list them explicitly; otherwise, pick the fields out of the response before returning it.
The same applies to filters. Never interpolate an argument into a query string, a filter, or a URL path. Build them from session values, or validate the argument against an allowlist.

Step 6: Keep private responses out of shared caches

Authentication is not the only way personal data can leak: even an authenticated response can end up stored in a cache shared by other visitors if nothing tells the GraphQL route to keep it private.
The route derives the cache-control header from the @cacheControl directive. When the directive is absent, a GET query response is cached as public, s-maxage=300 by default, unless the request carries a VtexIdclientAutCookie cookie, in which case the scope becomes private.
This means a custom query that returns personal data without requiring authentication is not only readable by anyone. Its response can also be stored in a shared cache and served to unrelated visitors. Requiring authentication as described in Step 1 addresses both problems, because an authenticated request always carries the cookie.
Declare the intent in your type definition as well:

_10
extend type Query {
_10
myPrivateField: MyType @cacheControl(scope: "private", sMaxAge: 0)
_10
}

Step 7: Keep credentials out of what you return

The resolver's catch block is a second surface. The GraphQL route protects what you throw: a plain error becomes a body-less 500, and a FastStoreError message is included in the response only outside production. It does not protect what you return, because a returned value is a data field like any other:

_10
// The message travels to the browser as data, unmasked.
_10
catch (error) {
_10
return { error: error.message }
_10
}


_10
// The detail stays in the log; the client receives a typed error.
_10
catch (error) {
_10
console.error('loyalty balance request failed', { status: error.status })
_10
_10
throw new UnauthorizedError('Authentication required')
_10
}

Keep the request URL and the headers out of the error text as well. The message always reaches your logs, and preview and development builds do expose it.
A credential that appeared in an error message should be treated as compromised and rotated, because the message may have travelled through logs, screenshots, and third-party error tools.

Using fixed credentials safely

A fixed credential remains the right choice for data with no shopper dimension, such as catalog enrichment, store locators, promotion metadata, and editorial content. It is also what a third-party integration uses even when the response is personal, as in Step 3. When you use one:
  • Store the credential as a secret or in the vtex.env file, never in your repository. Variables prefixed with NEXT_PUBLIC_ are sent to the browser, so a token must never use that prefix.
  • If a credential is currently a string literal in the source, moving it to an environment variable is not enough on its own. It remains in the repository history and stays valid until it is rotated, so rotate it as well.
  • Call every endpoint over https://. A plain http:// request carries the credential in cleartext.
  • Do not let the caller shape the upstream request. An argument that reaches a URL path or a filter without validation turns a service account into an open proxy.
  • Scope the credential to the permissions it needs, and rotate it when a partner's access ends.

Review checklist

Before deploying a custom resolver that handles shopper data, confirm the following.
The resolver checks authentication before any other logic.
Call requireAuthenticatedUser (or an equivalent check) as the first line of the resolver, before any data is read or written.
Identity comes from the session or the cookie, never from an argument.
An identifier accepted as a query or mutation argument lets the caller choose whose data to read or change. Derive it from ctx.clients.commerce.session('') or from the forwarded cookie instead.
Session reads use authentication.storeUserId, not profile.id, and an empty value throws instead of continuing.
profile.id can hold a value for a visitor who was identified without signing in. Use authentication.storeUserId, and throw UnauthorizedError when it is empty rather than letting the lookup continue with no filter.
Calls to VTEX APIs carry the shopper's cookie, not a fixed credential.
Forward ctx.headers.cookie so the upstream VTEX service authorizes the request against that shopper's permissions.
Calls to third-party APIs filter by an identifier taken from the session.
A third-party service has no notion of the VTEX cookie, so your resolver is the only barrier. Filter the request by the shopper ID taken from the session, never from an argument.
The response carries only the fields the interface renders.
Request the fields your interface renders, not the whole record. When an endpoint supports selecting fields, list them explicitly instead of asking for everything.
No argument is interpolated into a filter or a URL path.
Never interpolate an argument into a query string, a filter, or a URL path. Build them from session values, or validate the argument against an allowlist.
The field declares @cacheControl(scope: "private", sMaxAge: 0).
This keeps a personal response out of shared caches, even when the default caching behavior would otherwise apply.
No catch returns a raw error: the detail is logged and the response is generic.
A returned value is a data field like any other, so it is not protected the way a thrown error is. Log the detail and throw a typed error such as UnauthorizedError instead.
No credential is a string literal in the source, and any that was has been rotated.
A credential that was ever committed as a string literal remains in the repository history and stays valid until it is rotated, even after moving it to an environment variable.
Every outgoing call uses https://.
A plain http:// request carries the credential in cleartext.
Credentials come from environment variables without the NEXT_PUBLIC_ prefix.
Variables prefixed with NEXT_PUBLIC_ are sent to the browser, so a token must never use that prefix.
Whichever party authorizes the request is configured to do so.
When you forward the cookie, the service decides, so confirm that the endpoint really is scoped by the shopper. When you cannot forward it, your resolver decides, and it must filter by an identifier that came from the session.
The last item is the one most often missed. When you forward the cookie, the service decides, so confirm that the endpoint really is scoped by the shopper. When you cannot forward it, your resolver decides, and it must filter by an identifier that came from the session.
Contributors
2
Photo of the contributor Mariana Caetano
Photo of the contributor LarĂ­cia Mota
+ 2 contributors
Was this helpful?
Yes
No
Suggest Edits (GitHub)
Contributors
2
Photo of the contributor Mariana Caetano
Photo of the contributor LarĂ­cia Mota
+ 2 contributors
On this page
Step 1: Determine whether your resolver needs authentication
Was this helpful?
Suggest Edits (GitHub)
On this page