Plings API Security Guidelines (GraphQL)
This document provides guidelines for implementing authentication and authorization in the Plings GraphQL API to ensure a secure and robust implementation.
1. Authentication Strategy
Plings uses JWT-based authentication provided by Supabase. The token is passed from the client to the GraphQL server with every request and is used to establish user identity.
Token Transmission
- Method: The JWT access token must be included in the
Authorizationheader of every GraphQL request. - Format:
Authorization: Bearer <your-supabase-jwt>
Backend Implementation (GraphQL Context)
On the backend, the user’s session is resolved from this token and injected into the GraphQL context. This context object is then available to every resolver, making it the central source for all authorization checks.
# Example of adding user to GraphQL context in a FastAPI integration
async def get_context(request: Request):
token = request.headers.get("Authorization", "").split("Bearer ")[-1]
user = await resolve_user_from_token(token) # Your Supabase logic
return {
"request": request,
"user": user # The user object is now available to all resolvers
}
JWT Claim Design & RLS Mapping
The Auth webhook that runs at sign-in enriches every Supabase JWT with a compact set of namespaced custom claims. These are the only values referenced by RLS policies, which means each SQL policy can stay simple and index-friendly.
{
"role": "org_member", // system-wide role: guest | org_member | manufacturer_issuer | system_owner
"org_id": "acme-123", // current organisation the user operates in (UUID)
"org_role": "admin" // user's role **inside that org**: member | admin | owner
}
Origin of each claim
| Claim | Source table | Notes |
|——-|————–|——-|
| role | system_roles(user_id, system_role) | System Owner vs Manufacturer Issuer etc. |
| org_id, org_role | organisation_members(user_id, organisation_id, org_role) | A user may belong to many orgs—client decides which org context to request a session for. |
RLS sample (object read):
CREATE POLICY read_my_org_objects
ON object_instances
FOR SELECT
USING (
organisation_id = current_setting('request.jwt.claims', true)::json->>'org_id'
);
RLS sample (object write by owners/admins):
CREATE POLICY manage_objects_if_authorised
ON object_instances
FOR UPDATE USING (
(
-- Org admin/owner can touch anything in their org
(current_setting('request.jwt.claims', true)::json->>'org_role') IN ('admin','owner')
AND organisation_id = current_setting('request.jwt.claims', true)::json->>'org_id'
)
OR
-- Or the row is personally owned by the JWT subject
(owner_id = auth.uid())
);
The GraphQL resolver layer performs only higher-level business checks (e.g. a Manufacturer Issuer can only mint identifiers under their permitted HD-wallet path). If the resolver accidentally allows more than it should, the RLS barrier still prevents leakage.
2. Authorization: Resolver-Level Permissions
In a GraphQL API, authorization logic moves from the endpoint level (as in REST) to the resolver level. Every field in your schema can have its own permission check.
Guiding Principles
- Check Permissions Early: Perform authorization checks at the beginning of a resolver before executing any expensive database operations.
- Default to Deny: Access should be denied unless explicitly granted.
- Leverage the Context: All authorization logic should be based on the
userobject available in the GraphQL context.
Example: Protected Resolver
This example shows a resolver for a myObjects query that should only return objects belonging to the authenticated user.
# schema.graphql
type Query {
myObjects: [ObjectInstance!]
}
# resolver.py
from ariadne import ObjectType
query = ObjectType("Query")
@query.field("myObjects")
def resolve_my_objects(_, info):
# 1. Get the user from the context
user = info.context.get("user")
# 2. Check for authentication
if not user:
# You can return None, an empty list, or raise a specific error
raise Exception("Authentication required")
# 3. Fetch data using the user's identity
user_id = user.id
objects = database.get_objects_for_user(user_id) # Your database logic
return objects
3. Query Security & Abuse Prevention
A single GraphQL endpoint can be a target for complex queries that could overwhelm the database (Denial of Service). It’s critical to implement safeguards.
Query Depth Limiting
- Purpose: Prevent deeply nested queries (e.g., asking for an object’s parent’s parent’s parent…).
- Implementation: Use a validation rule that rejects any query exceeding a maximum depth (e.g., 7-10 levels). Many GraphQL server libraries support this out-of-the-box or via plugins.
Query Cost Analysis
- Purpose: Assign a “cost” to different fields and limit the total cost of a single query. Fields that require more computation or database work (e.g., a list of related objects) have a higher cost.
- Example:
object.name: cost = 1object.relatedObjects: cost = 10
- Implementation: Set a maximum query cost (e.g., 1000) and calculate the total cost of an incoming query before executing it. Reject queries that exceed the limit.
Persisted Queries
- Purpose: For production environments, you can disable arbitrary queries and only allow a pre-approved list of queries that have been stored on the server.
- Benefit: This provides the strongest security against malicious queries, as only known, optimized queries can be run.
4. Preventing Data Leakage
Error Handling
By default, GraphQL will return a 200 OK status even if errors occur during resolution, with an errors object in the response body.
- Don’t Leak Stack Traces: Ensure your production environment is configured to suppress detailed internal error messages and stack traces, which can reveal information about your server’s implementation.
- Custom Error Types: Use custom, sanitized error types to provide meaningful feedback to the client without exposing internal details.
// Example of a safe error response
{
"errors": [
{
"message": "Access denied: You do not have permission to view this object.",
"path": ["object"],
"extensions": {
"code": "FORBIDDEN"
}
}
],
"data": {
"object": null
}
}
Disabling Introspection
- Purpose: The GraphQL introspection system allows clients to ask the schema about the queries it supports. This is extremely useful in development but can provide attackers with a complete map of your API in production.
- Recommendation: Disable introspection in your production environment.
5. Security for File Uploads
File uploads are handled separately from standard GraphQL mutations, typically using a multipart request specification.
- Authentication: The mutation resolver that processes the upload must still perform the same authentication and authorization checks using the GraphQL context.
- File Validation: Do not trust file metadata from the client (e.g., content type). Validate the file’s contents on the server.
- Storage Security: Ensure that files are uploaded to a secure location (like Supabase Storage) with appropriate access policies.
0. GraphQL Endpoint Location (UPDATED 2025-06-29)
All clients must call the backend at the exact path
https://plings-backend.vercel.app/graphql/
The trailing slash matters because FastAPI mounts the GraphQL sub-app with app.mount("/graphql", …), and Starlette treats that like a directory mount. When you hit /graphql without the slash it responds with a 307 Temporary Redirect to /graphql/. That extra hop is harmless but wastes one RTT and has confused Safari/Apollo in the past, surfacing as “Load failed”. Always include the slash to skip the redirect.
Ensure your ApolloClient, environment variables and cURL/insomnia tests use the slash-less form.
By implementing these strategies, you can build a GraphQL API that is not only powerful and flexible but also secure and resilient.