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

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

  1. Check Permissions Early: Perform authorization checks at the beginning of a resolver before executing any expensive database operations.
  2. Default to Deny: Access should be denied unless explicitly granted.
  3. Leverage the Context: All authorization logic should be based on the user object 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

Query Cost Analysis

Persisted Queries

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.

// 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

5. Security for File Uploads

File uploads are handled separately from standard GraphQL mutations, typically using a multipart request specification.

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.