how cache key factors handle customer group dependent prices correctly
Once prices or visibility depend on customer group, a naive cache can serve the same response to different customers, with wrong prices in the worst case. This article explains how Magento's built-in GraphQL response cache already folds customer group into its cache key by default, where custom resolver-level caches can still get it wrong, and how this plays together correctly with Varnish and a CDN in front of it.
Table of Contents
- 1. Why customer group dependent prices break naive response caching
- 2. Magento's built-in mechanism: CacheIdCalculator and the customer group factor
- 3. Where it still goes wrong: resolver-level caching as a separate layer
- 4. Real-world example: a manually cached resolver missing the customer group component
- 5. Writing a custom CacheIdFactorProviderInterface for extra context
- 6. Interaction with Varnish: the header flows into the VCL hash
- 7. CDN edge caching in front of Varnish: the actual risk
- 8. How this differs from the general Varnish GraphQL article
- 9. Testing and verifying against price leakage between customer groups
- 10. Summary
- 11. FAQ
1. Why customer group dependent prices break naive response caching
Once prices, negotiated terms, or the visibility of individual products depend on customer group, the very same GraphQL query returns different, each individually correct, responses depending on the requesting customer. A cache that keys purely on URL or raw query text cannot represent that difference, and risks serving the wrong response to the wrong customer.
In the worst case, a customer group with negotiated terms gets served the response computed for a different group straight out of the cache, with wrong prices or incorrectly visible or invisible products. This failure mode is easy to miss in testing with a single test account, since that never compares two different customer groups against each other.
What makes this particularly tricky is that such a bug usually does not stand out immediately, the response is syntactically perfectly valid, the query returns data, and no error message points to a problem. Only a deliberate comparison between two customer groups, or a complaint from a customer who noticed a wrong price, actually surfaces the issue, which is why customer group aware caching deserves to be designed in from the start rather than patched in afterward.
2. Magento's built-in mechanism: CacheIdCalculator and the customer group factor
Since the Magento_GraphQlCache module, Magento\GraphQlCache\Model\CacheId\CacheIdCalculator computes an X-Magento-Cache-Id value for every cacheable request, a hash over several registered factors. Each factor implements CacheIdFactorProviderInterface with the methods getFactorName and getFactorValue.
Customer group is already included out of the box, Magento\CustomerGraphQl\CacheIdFactorProviders\CustomerGroupProvider supplies the customer group ID as the CUSTOMER_GROUP factor, complemented by store, currency, tax rate, and login status from neighboring providers. The built-in response cache already separates different customer groups correctly.
<?php
declare(strict_types=1);
namespace Magento\GraphQlCache\Model\CacheId;
use Magento\Framework\GraphQl\Query\Resolver\ContextInterface;
/**
* Contract for a single factor that feeds into the X-Magento-Cache-Id calculation.
*/
interface CacheIdFactorProviderInterface
{
/**
* Unique name of the factor within the hash calculation.
*
* @return string
*/
public function getFactorName(): string;
/**
* Current value of the factor for the given GraphQL request context.
*
* @param ContextInterface $context
* @return string
*/
public function getFactorValue(ContextInterface $context): string;
}
3. Where it still goes wrong: resolver-level caching as a separate layer
The response cache is not the only cache layer, Magento_GraphQlResolverCache allows caching individual, expensive resolver results through Resolver\IdentityInterface and its own cache key mechanism, independent of whether the complete response is response-cacheable at all.
This mechanism does not know about customer group automatically. Any custom resolver that participates in the resolver cache must fold the relevant context dimensions into its own cache key itself, otherwise exactly the mixing that the response cache already prevents happens at this layer instead.
4. Real-world example: a manually cached resolver missing the customer group component
An obvious, and in practice common, mistake happens when a developer caches an expensive custom resolver directly with CacheInterface, for example the result of an expensive ERP price calculation, and builds the cache key from the product SKU alone.
The corrected version takes the customer group ID from the GraphQL context as an additional component of the cache key, which guarantees that two customer groups share the same resolver code but never the same cache entry.
<?php
declare(strict_types=1);
namespace Vendor\ErpPricing\Model\Resolver;
use Magento\Framework\App\CacheInterface;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
/**
* Example resolver that caches an expensive ERP price calculation.
*/
class ErpNegotiatedPrice implements ResolverInterface
{
/**
* @param CacheInterface $cache
*/
public function __construct(private readonly CacheInterface $cache)
{
}
/**
* @param Field $field
* @param mixed $context
* @param ResolveInfo $info
* @param array|null $value
* @param array|null $args
* @return float
*/
public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null): float
{
$sku = $value['sku'];
// WRONG: the cache key only contains the SKU, every customer group shares the same entry.
// $cacheKey = 'erp_price_' . $sku;
// CORRECT: the customer group ID from the GraphQL context extends the cache key,
// so two customer groups never share the same price cache entry.
$customerGroupId = $context->getExtensionAttributes()->getCustomerGroupId();
$cacheKey = 'erp_price_' . $sku . '_group_' . $customerGroupId;
$cached = $this->cache->load($cacheKey);
if ($cached !== false) {
return (float) $cached;
}
$price = $this->fetchNegotiatedPriceFromErp($sku, $customerGroupId);
$this->cache->save((string) $price, $cacheKey, [], 600);
return $price;
}
/**
* @param string $sku
* @param int $customerGroupId
* @return float
*/
private function fetchNegotiatedPriceFromErp(string $sku, int $customerGroupId): float
{
// ... expensive ERP call, heavily simplified here
return 0.0;
}
}
5. Writing a custom CacheIdFactorProviderInterface for extra context
If customer group alone is not enough, for example because B2B companies within the same customer group get different negotiated prices through a shared catalog, an extra factor can be added analogous to the core pattern.
Registration happens as another array item on the idFactorProviders constructor argument of CacheIdCalculator, following exactly the same pattern the core already uses to wire up store, currency, and customer group providers.
<?php
declare(strict_types=1);
namespace Vendor\B2bCaching\Model\CacheIdFactorProviders;
use Magento\Framework\GraphQl\Query\Resolver\ContextInterface;
use Magento\GraphQlCache\Model\CacheId\CacheIdFactorProviderInterface;
/**
* Adds the B2B company ID as its own factor in the cache ID calculation,
* for cases where the same customer group has different company prices.
*/
class CompanyIdProvider implements CacheIdFactorProviderInterface
{
/**
* @return string
*/
public function getFactorName(): string
{
return 'COMPANY_ID';
}
/**
* @param ContextInterface $context
* @return string
*/
public function getFactorValue(ContextInterface $context): string
{
return (string) ($context->getExtensionAttributes()->getCompanyId() ?? '0');
}
}
6. Interaction with Varnish: the header flows into the VCL hash
The bundled Varnish VCL folds the X-Magento-Cache-Id header computed by Magento directly into its own hash calculation, complemented by store and Content-Currency headers as well as an addition for authenticated requests carrying a bearer token.
Varnish itself does not need to know anything about customer groups for this, it relies entirely on Magento performing the calculation correctly. A response is additionally only ever cached when the cache ID value sent with the request matches the one in the response.
# Excerpt from the bundled varnish6.vcl, simplified
sub process_graphql_headers {
if (req.http.X-Magento-Cache-Id) {
hash_data(req.http.X-Magento-Cache-Id);
hash_data(req.http.Store);
hash_data(req.http.Content-Currency);
if (req.http.Authorization) {
hash_data("Authorized");
}
}
}
7. CDN edge caching in front of Varnish: the actual risk
An additional CDN in front of Varnish brings its own caching rules that do not necessarily respect Magento's headers. If a CDN caches GraphQL responses purely by URL, for example for GET requests using persisted queries, it can serve incorrect, cross-group responses despite correct Magento and Varnish behavior.
The fix is either to configure the CDN so that the relevant Magento headers explicitly feed into its own cache key, or to exclude GraphQL requests from caching at the CDN entirely and leave that job fully to Varnish.
8. How this differs from the general Varnish GraphQL article
A general article on Varnish configuration for GraphQL typically covers fundamentals such as VCL structure, cache tags for invalidation, and timeouts. This article picks up exactly where customer group concretely flows into the cache key calculation, at both the Magento and the CDN level.
Anyone combining both topics should first make sure the basic Varnish setup is correct, and only then check the customer group specific factors and resolver cache pitfalls described here, since a broken base configuration makes any further optimization worthless.
9. Testing and verifying against price leakage between customer groups
The most reliable test runs the same query with two different, authenticated customer groups and compares both the returned prices and the returned X-Magento-Cache-Id value, both must differ between the groups.
An automated regression test for exactly this comparison is worth adding as well, so a future, carelessly added manual resolver cache does not silently reopen exactly the gap the built-in mechanism already closed.
| Layer | Mechanism | Customer group included automatically? | Responsible for |
|---|---|---|---|
| GraphQL response cache | CacheIdCalculator plus CacheIdFactorProviderInterface | Yes, via CustomerGroupProvider | Caching and reusing the complete response |
| Resolver or field cache | Magento_GraphQlResolverCache, Resolver\IdentityInterface | No, must be added per resolver | Caching individual expensive field results |
| Custom manual resolver cache | Direct CacheInterface call inside resolver code | No, only if the developer adds it | Ad-hoc caching in custom resolvers |
| Varnish | VCL hash over X-Magento-Cache-Id, Store, Content-Currency | Yes, indirectly via the adopted header | Edge caching complete HTTP responses |
| External CDN in front of Varnish | Depends on the CDN configuration | Only if headers are explicitly respected | Global edge caching |
Mironsoft
Magento development, module consulting, and system architecture
A Magento project that needs a second opinion or experienced execution?
We build custom Magento modules, advise on architecture decisions, and take on complex implementations, from service contract planning to production-ready deployment.
Architecture Consulting
Have module and system architecture thought through properly before you build.
Custom Module Development
Build custom Magento modules cleanly, following best practices.
Code Review & Audit
Have existing modules reviewed for performance, security, and maintainability.
10. Summary
GraphQL Caching Per Customer Group in Magento: The Essentials
Good starting point
The built-in response cache already accounts for customer group by default via CustomerGroupProvider.
Real risk
Custom, manually cached resolver results missing the customer group component in the cache key.
Extension
Add additional context dimensions such as a B2B company ID through a custom CacheIdFactorProviderInterface.
Edge layer
Varnish adopts the header Magento computes, a CDN in front of it must respect it explicitly.