#676 — GraphQL requests send full query strings, increasing payload size and exposing the schema to arbitrary query abuse.
Implemented Automatic Persisted Queries (APQ) protocol in Apollo Server with Redis-backed storage, enforcing only pre-registered queries in production.
Apollo Server plugin that intercepts every GraphQL request and enforces APQ rules:
-
Production mode (
NODE_ENV=production):- Requires
extensions.persistedQuery.sha256Hashon every request - Validates the hash exists in the Redis store
- Verifies the submitted query text matches the stored query (prevents query smuggling)
- Replaces
request.querywith the stored canonical version - Rejects with
PERSISTED_QUERY_REQUIREDorPERSISTED_QUERY_NOT_FOUNDerrors
- Requires
-
Development mode:
- Allows arbitrary queries without registration
- No persisted query hash required
Redis-backed service for managing the persisted query store:
- Uses
iorediswith SHA-256 hashing - Key prefix:
apq:{sha256hash} - TTL: 30 days (configurable via
TTL_SECONDS) - Operations:
hashQuery(query)— generates SHA-256 hashstoreQuery(hash, query)— stores with TTLgetQuery(hash)— retrieves stored queryexists(hash)— checks existenceregisterQuery(query)— hashes and storesregisterQueries(queries[])— batch registrationgetQueryCount()— monitoring
Defines 15 standard GraphQL operations approved for production use:
Queries:
Me— authenticated user profileRecord— single medical record by IDRecords— paginated medical records with filtersAccessGrants— access grants for patientAuditLog— paginated audit trailProvider— public provider profileProviders— provider directoryPatient— patient by IDPatients— patient listing
Mutations:
UploadRecord— upload new medical recordGrantAccess— grant provider access to recordRevokeAccess— revoke access grantUpdateProfile— update user profileRegisterDevice— register push notification deviceSubmitGdprRequest— submit GDPR data request
CLI script for deploy-time registration:
npm run register:graphql-queriesOutputs:
- Each registered hash with query preview
- Total count of persisted queries in Redis
7 comprehensive unit tests covering all enforcement scenarios:
| Test | Description |
|---|---|
| ✅ | Dev mode without persistedQuery extension — query allowed |
| ✅ | Production mode without persistedQuery extension — rejected with PERSISTED_QUERY_REQUIRED |
| ✅ | Production mode with empty persistedQuery — rejected |
| ✅ | Production mode with unknown hash — rejected with PERSISTED_QUERY_NOT_FOUND |
| ✅ | Production mode with known hash — query allowed |
| ✅ | Production mode hash mismatch — rejected with PERSISTED_QUERY_MISMATCH |
| ✅ | Dev mode with unknown hash — allowed without storing |
| Variable | Default | Description |
|---|---|---|
REDIS_URL |
— | Redis connection URL (takes precedence) |
REDIS_HOST |
localhost |
Redis host |
REDIS_PORT |
6379 |
Redis port |
REDIS_PASSWORD |
'' |
Redis password |
NODE_ENV |
development |
Determines APQ enforcement |
Add to deployment pipeline (CI/CD, Docker entrypoint, etc.):
# Ensure Redis is running, then register queries:
npm run register:graphql-queries{
"query": "...", // Required but replaced by server-stored version
"variables": {},
"extensions": {
"persistedQuery": {
"sha256Hash": "abc123..."
}
}
}| Code | HTTP Status | Description |
|---|---|---|
PERSISTED_QUERY_REQUIRED |
400 | Missing or empty persistedQuery.sha256Hash in production |
PERSISTED_QUERY_NOT_FOUND |
400 | Hash not found in Redis store |
PERSISTED_QUERY_MISMATCH |
400 | Submitted query doesn't match stored query for hash |
- Reduced Payload Size — Clients send only a hash (~32 bytes) instead of full query text
- Schema Protection — Only pre-approved queries execute in production; arbitrary query abuse prevented
- Query Plan Cacheability — Apollo Server can cache query plans more reliably
- Operational Safety — Deploy-time registration ensures only reviewed queries go live
Run the APQ tests:
npx jest --selectProjects unit --testPathPatterns 'apq.plugin.spec.ts'All 7 tests pass ✅
- PR: Healthy-Stellar#725
- Branch:
feat/graphql-persisted-queries-apq-676
closes #676