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 credentials | Shopper credentials | |
|---|---|---|
| Examples | X-VTEX-API-AppKey and X-VTEX-API-AppToken, an integration Bearer token, a partner key in vtex.env | VtexIdclientAutCookie_{account} |
| Caller identity | The store | The signed-in shopper |
| Permissions | The permissions the credential holds, usually broad | What that one shopper may access |
| Enforced by | Your resolver code only | The 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:
- Does the response differ from one shopper to the next?
- 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.
- 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:
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:
_10const 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:
_10const 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 asCL,NL,AD, or any custom entity keyed by email or document number. AddingappKeyandappTokento 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: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:
Derive the identity from the session instead:
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.unitIdis the B2B organization unit, andcustomerIdis 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.
VTEX APIs: forward the cookie
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:
/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:
_10import { parse } from 'cookie'_10_10const token = parse(ctx.headers?.cookie ?? '')[_10 `VtexIdclientAutCookie_${ctx.account}`_10]
Use a parser such as thecookiepackage rather thansplit('=')[1]. Token values can contain=, and truncating one causes a silent401response.
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:
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:
_10extend 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._10catch (error) {_10 return { error: error.message }_10}
_10// The detail stays in the log; the client receives a typed error._10catch (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.envfile, never in your repository. Variables prefixed withNEXT_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 plainhttp://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.