You are viewing a preview of this lesson. Sign in to start learning
Back to Mastering Relay

Data Masking Architecture

Understanding Relay's data isolation boundaries and access control

Data Masking Architecture in Relay

Master data masking patterns in Relay with free flashcards and spaced repetition practice. This lesson covers field-level masking strategies, authorization boundaries, and secure data exposureβ€”essential concepts for building production-ready GraphQL applications that protect sensitive information.

Welcome to Data Masking πŸ”

In modern applications, not all data should be visible to all users. A user's email might be visible to them but hidden from others. Admin panels expose data that regular users should never see. Medical records require strict access controls. This is where data masking becomes critical.

Relay's fragment-based architecture provides a powerful foundation for implementing data masking at the component level. Instead of filtering data in UI code (which can leak information), you mask data at the GraphQL layer before it ever reaches the client.

πŸ’‘ Key Insight: Data masking in Relay isn't about hiding UI elementsβ€”it's about preventing unauthorized data from being fetched in the first place.


Core Architecture Principles πŸ—οΈ

1. Server-Side Authorization is Primary

Data masking in Relay starts with your GraphQL server. The server must:

  • Enforce authorization rules before resolving fields
  • Return null or throw errors for unauthorized access
  • Never leak data through error messages or partial responses
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚         DATA MASKING LAYERS            β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                         β”‚
β”‚  πŸ”’ Layer 1: Server Authorization      β”‚
β”‚      └─ Resolver-level checks          β”‚
β”‚                                         β”‚
β”‚  πŸ“‹ Layer 2: Schema Design             β”‚
β”‚      └─ Type-level permissions         β”‚
β”‚                                         β”‚
β”‚  🎭 Layer 3: Fragment Masking          β”‚
β”‚      └─ Component-level isolation      β”‚
β”‚                                         β”‚
β”‚  🎨 Layer 4: UI Rendering              β”‚
β”‚      └─ Conditional display (backup)   β”‚
β”‚                                         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Example server-side resolver:

const userResolvers = {
  User: {
    email: (parent, args, context) => {
      // Only owner or admin can see email
      if (context.currentUser.id !== parent.id && !context.currentUser.isAdmin) {
        return null; // Masked!
      }
      return parent.email;
    },
    
    socialSecurityNumber: (parent, args, context) => {
      // Extremely sensitive - strict rules
      if (!context.currentUser.isAdmin || !context.auditLog) {
        throw new Error('Unauthorized access');
      }
      context.auditLog.record('SSN_ACCESS', parent.id);
      return parent.ssn;
    }
  }
};

⚠️ Common Mistake: Returning the actual data from the server and trying to hide it in the UI. By the time data reaches the client, it's already exposed in network requests!

2. Schema-Level Masking with Directives

Use GraphQL schema directives to declare authorization requirements:

type User {
  id: ID!
  name: String!
  email: String! @requireAuth(role: "OWNER_OR_ADMIN")
  privateNotes: String @requireAuth(role: "ADMIN")
  lastLoginIP: String @requireAuth(role: "ADMIN") @audit
}

directive @requireAuth(
  role: String!
) on FIELD_DEFINITION

directive @audit on FIELD_DEFINITION

This makes authorization rules visible in your schema and enforceable through middleware.

3. Fragment Masking Enforces Boundaries

Relay's fragment masking ensures components can only access data they explicitly request:

import { graphql, useFragment } from 'react-relay';

function UserProfile({ userRef }) {
  const data = useFragment(
    graphql`
      fragment UserProfile_user on User {
        id
        name
        # email is NOT requested here
        # So this component CANNOT access it
      }
    `,
    userRef
  );
  
  // data.email is undefined - not in fragment!
  return <div>{data.name}</div>;
}

Why this matters: Even if a parent component fetched email, this component cannot access it without explicitly including it in its fragment. This prevents accidental data leaks.

Without Fragment Masking With Fragment Masking
Component can access ANY data from parent Component can ONLY access declared fields
Refactoring risks exposing sensitive data Explicit dependencies prevent leaks
Hard to audit what data flows where Clear data boundaries per component

Masking Patterns 🎭

Pattern 1: Null Masking (Soft Hide)

Return null for masked fields. The field exists but contains no data.

// Component handles nullable data
function ContactCard({ userRef }) {
  const data = useFragment(
    graphql`
      fragment ContactCard_user on User {
        name
        email  # Might be null if not authorized
        phone  # Might be null if not authorized
      }
    `,
    userRef
  );
  
  return (
    <div>
      <h2>{data.name}</h2>
      {data.email && <p>Email: {data.email}</p>}
      {data.phone && <p>Phone: {data.phone}</p>}
    </div>
  );
}

When to use: When the UI can gracefully handle missing data, and you want to reuse the same component for different authorization contexts.

Pattern 2: Error Masking (Hard Block)

Throw an error if unauthorized. The query fails entirely.

// Server resolver
email: (parent, args, context) => {
  if (!canViewEmail(context.user, parent)) {
    throw new GraphQLError('Unauthorized', {
      extensions: { code: 'FORBIDDEN' }
    });
  }
  return parent.email;
}

When to use: For highly sensitive data where any unauthorized access attempt should be logged and blocked. Medical records, financial data, admin-only information.

Pattern 3: Partial Masking (Redaction)

Return partially masked data (e.g., "****@example.com" or "555-***-1234").

// Server helper
function maskEmail(email, canViewFull) {
  if (canViewFull) return email;
  const [local, domain] = email.split('@');
  return `${local[0]}***@${domain}`;
}

// Resolver
email: (parent, args, context) => {
  const canViewFull = context.user.id === parent.id;
  return maskEmail(parent.email, canViewFull);
}

When to use: When you need to show that data exists (for verification) without exposing full details.

Pattern 4: Type-Based Masking (Interfaces)

Use GraphQL interfaces to expose different fields based on viewer type:

interface UserProfile {
  id: ID!
  name: String!
}

type PublicProfile implements UserProfile {
  id: ID!
  name: String!
  bio: String
}

type PrivateProfile implements UserProfile {
  id: ID!
  name: String!
  bio: String
  email: String!
  phone: String
  privateNotes: String
}

type Query {
  viewUser(id: ID!): UserProfile  # Returns appropriate type based on auth
}

The server returns PublicProfile for strangers, PrivateProfile for the user themselves.

When to use: When different user roles need completely different data shapes, and you want type safety.


Example 1: User Profile with Email Masking

Scenario: A user profile page where email is only visible to the profile owner or admins.

Schema:

type User {
  id: ID!
  name: String!
  email: String  # Can be null if not authorized
  bio: String
  createdAt: DateTime!
}

Server Resolver:

const resolvers = {
  User: {
    email: (user, args, context) => {
      const viewer = context.currentUser;
      
      // Owner can see their own email
      if (viewer && viewer.id === user.id) {
        return user.email;
      }
      
      // Admins can see all emails
      if (viewer && viewer.role === 'ADMIN') {
        return user.email;
      }
      
      // Everyone else gets null
      return null;
    }
  }
};

Relay Component:

import { graphql, useFragment } from 'react-relay';

function UserProfileCard({ userRef }) {
  const user = useFragment(
    graphql`
      fragment UserProfileCard_user on User {
        id
        name
        email  # Requested but might be null
        bio
        createdAt
      }
    `,
    userRef
  );
  
  return (
    <div className="profile-card">
      <h1>{user.name}</h1>
      {user.email && (
        <p className="email">
          πŸ“§ {user.email}
        </p>
      )}
      <p>{user.bio}</p>
      <small>Member since {new Date(user.createdAt).getFullYear()}</small>
    </div>
  );
}

Explanation: The component always requests email, but the server conditionally returns it. The UI gracefully handles both casesβ€”showing the email when available, hiding it when not.


Example 2: Admin Panel with Error Boundaries

Scenario: An admin dashboard that should be completely inaccessible to non-admins.

Schema:

type AdminStats @requireAuth(role: "ADMIN") {
  totalUsers: Int!
  totalRevenue: Float!
  activeSubscriptions: Int!
  flaggedAccounts: [User!]!
}

type Query {
  adminDashboard: AdminStats
}

Server Resolver:

const resolvers = {
  Query: {
    adminDashboard: (parent, args, context) => {
      if (!context.currentUser || context.currentUser.role !== 'ADMIN') {
        throw new GraphQLError('Admin access required', {
          extensions: { code: 'FORBIDDEN' }
        });
      }
      
      return getAdminStats();
    }
  }
};

Relay Component with Error Handling:

import { graphql, useLazyLoadQuery } from 'react-relay';
import { useEffect, useState } from 'react';

function AdminDashboard() {
  const [error, setError] = useState(null);
  
  let data;
  try {
    data = useLazyLoadQuery(
      graphql`
        query AdminDashboardQuery {
          adminDashboard {
            totalUsers
            totalRevenue
            activeSubscriptions
            flaggedAccounts {
              id
              name
            }
          }
        }
      `,
      {}
    );
  } catch (err) {
    if (err.source?.errors?.[0]?.extensions?.code === 'FORBIDDEN') {
      return <div className="error">β›” Access Denied: Admin privileges required</div>;
    }
    throw err; // Re-throw unexpected errors
  }
  
  return (
    <div className="admin-dashboard">
      <h1>Admin Dashboard</h1>
      <div className="stats">
        <StatCard label="Total Users" value={data.adminDashboard.totalUsers} />
        <StatCard label="Revenue" value={`$${data.adminDashboard.totalRevenue}`} />
        <StatCard label="Active Subs" value={data.adminDashboard.activeSubscriptions} />
      </div>
      <FlaggedAccountsList accounts={data.adminDashboard.flaggedAccounts} />
    </div>
  );
}

Explanation: The server throws an error for unauthorized access. The component catches the specific error code and shows an access-denied message. This prevents any admin data from being sent to unauthorized clients.


Example 3: Multi-Level Masking with Partial Data

Scenario: A social network where users control their privacy settings.

Schema:

enum PrivacyLevel {
  PUBLIC
  FRIENDS
  PRIVATE
}

type User {
  id: ID!
  name: String!
  email: String           # PRIVATE only
  phone: String           # PRIVATE only
  location: String        # FRIENDS or PUBLIC
  bio: String             # PUBLIC
  friendCount: Int        # PUBLIC (number only)
  friends: [User!]        # FRIENDS only (actual list)
}

Server Resolver with Privacy Logic:

function getRelationship(viewer, targetUser) {
  if (!viewer) return 'PUBLIC';
  if (viewer.id === targetUser.id) return 'OWNER';
  if (targetUser.friendIds.includes(viewer.id)) return 'FRIENDS';
  return 'PUBLIC';
}

const resolvers = {
  User: {
    email: (user, args, context) => {
      const rel = getRelationship(context.currentUser, user);
      return rel === 'OWNER' ? user.email : null;
    },
    
    phone: (user, args, context) => {
      const rel = getRelationship(context.currentUser, user);
      return rel === 'OWNER' ? user.phone : null;
    },
    
    location: (user, args, context) => {
      const rel = getRelationship(context.currentUser, user);
      if (user.privacySettings.location === 'PRIVATE' && rel !== 'OWNER') {
        return null;
      }
      if (user.privacySettings.location === 'FRIENDS' && rel === 'PUBLIC') {
        return null;
      }
      return user.location;
    },
    
    friends: (user, args, context) => {
      const rel = getRelationship(context.currentUser, user);
      // Only owner and friends can see friend list
      if (rel === 'PUBLIC') return null;
      return user.getFriends();
    }
  }
};

Relay Component:

function UserProfileFull({ userRef }) {
  const user = useFragment(
    graphql`
      fragment UserProfileFull_user on User {
        name
        email
        phone
        location
        bio
        friendCount  # Always available
        friends {    # Might be null
          id
          name
        }
      }
    `,
    userRef
  );
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
      
      {user.location && <p>πŸ“ {user.location}</p>}
      {user.email && <p>πŸ“§ {user.email}</p>}
      {user.phone && <p>πŸ“ž {user.phone}</p>}
      
      <div>
        <strong>Friends: {user.friendCount}</strong>
        {user.friends ? (
          <ul>
            {user.friends.map(friend => <li key={friend.id}>{friend.name}</li>)}
          </ul>
        ) : (
          <p className="muted">Friend list is private</p>
        )}
      </div>
    </div>
  );
}

Explanation: Different fields have different privacy rules. The server applies these consistently, and the component handles all possible null cases. The UI shows what data is available without revealing why it's not available (privacy settings).


Example 4: Field-Level Audit Logging

Scenario: Sensitive fields require access logging for compliance.

Schema with Audit Directive:

type Patient {
  id: ID!
  name: String!
  dateOfBirth: Date!
  medicalRecordNumber: String! @requireAuth(role: "MEDICAL_STAFF") @audit
  diagnosis: String @requireAuth(role: "DOCTOR") @audit
  medications: [Medication!] @requireAuth(role: "MEDICAL_STAFF") @audit
}

directive @audit(
  level: AuditLevel = STANDARD
) on FIELD_DEFINITION

Server Middleware:

const auditMiddleware = {
  User: {
    medicalRecordNumber: async (resolve, parent, args, context, info) => {
      // Check authorization
      if (!context.user || !context.user.roles.includes('MEDICAL_STAFF')) {
        throw new GraphQLError('Unauthorized access to medical records', {
          extensions: { code: 'FORBIDDEN' }
        });
      }
      
      // Log the access
      await context.auditLog.create({
        userId: context.user.id,
        action: 'ACCESS_MEDICAL_RECORD',
        resourceType: 'Patient',
        resourceId: parent.id,
        fieldName: 'medicalRecordNumber',
        timestamp: new Date(),
        ipAddress: context.req.ip
      });
      
      // Resolve the field
      const result = await resolve(parent, args, context, info);
      return result;
    }
  }
};

Relay Component:

function PatientRecordView({ patientRef }) {
  const patient = useFragment(
    graphql`
      fragment PatientRecordView_patient on Patient {
        id
        name
        dateOfBirth
        medicalRecordNumber  # Access logged automatically
        diagnosis            # Access logged automatically
        medications {        # Access logged automatically
          id
          name
          dosage
        }
      }
    `,
    patientRef
  );
  
  return (
    <div className="patient-record">
      <header>
        <h2>{patient.name}</h2>
        <span className="mrn">MRN: {patient.medicalRecordNumber}</span>
      </header>
      
      <section>
        <h3>Diagnosis</h3>
        <p>{patient.diagnosis}</p>
      </section>
      
      <section>
        <h3>Current Medications</h3>
        <ul>
          {patient.medications.map(med => (
            <li key={med.id}>{med.name} - {med.dosage}</li>
          ))}
        </ul>
      </section>
    </div>
  );
}

Explanation: Every access to sensitive fields is automatically logged by server middleware. The component doesn't need special audit codeβ€”it just requests data normally. The server handles authorization and audit trail creation transparently.


Common Mistakes ⚠️

Mistake 1: Client-Side Masking Only

❌ Wrong approach:

function UserProfile({ user }) {
  // Data already fetched from server!
  const canViewEmail = user.id === currentUser.id;
  
  return (
    <div>
      <h1>{user.name}</h1>
      {canViewEmail && <p>{user.email}</p>}  {/* Already exposed in network! */}
    </div>
  );
}

Problem: The email was already sent to the client in the GraphQL response. Anyone inspecting network traffic can see it.

βœ… Correct approach: Mask at the server, handle null in UI.


Mistake 2: Forgetting Nullable Types in Schema

❌ Wrong schema:

type User {
  email: String!  # Non-nullable!
}

If your resolver returns null for unauthorized access, GraphQL will throw an error and potentially null out the entire parent object!

βœ… Correct schema:

type User {
  email: String  # Nullable - can be null when masked
}

Mistake 3: Leaking Information Through Error Messages

❌ Wrong error:

throw new Error(`User ${userId} denied access to email ${email}`);
// Leaks the email in the error message!

βœ… Correct error:

throw new GraphQLError('Unauthorized field access', {
  extensions: { code: 'FORBIDDEN' }
});
// Generic message, no data leak

Mistake 4: Not Handling Null Everywhere

❌ Brittle component:

function ContactInfo({ userRef }) {
  const user = useFragment(fragment, userRef);
  
  return <a href={`mailto:${user.email}`}>{user.email}</a>;
  // Crashes if email is null!
}

βœ… Defensive component:

function ContactInfo({ userRef }) {
  const user = useFragment(fragment, userRef);
  
  if (!user.email) return null;  // Gracefully handle masked data
  
  return <a href={`mailto:${user.email}`}>{user.email}</a>;
}

Mistake 5: Inconsistent Masking Rules

Having different masking logic in different resolvers leads to security gaps.

βœ… Solution: Centralize authorization logic:

// Shared authorization module
const authRules = {
  canViewEmail: (viewer, targetUser) => {
    return viewer && (viewer.id === targetUser.id || viewer.isAdmin);
  },
  canViewPrivateData: (viewer, targetUser) => {
    return viewer && viewer.id === targetUser.id;
  }
};

// Use consistently in all resolvers
const resolvers = {
  User: {
    email: (user, args, context) => {
      return authRules.canViewEmail(context.currentUser, user) 
        ? user.email 
        : null;
    }
  }
};

Advanced Patterns πŸš€

Dynamic Fragments Based on Authorization

You can conditionally include fragments based on the current user's permissions:

function UserProfile({ userRef, isOwner }) {
  const publicData = useFragment(
    graphql`
      fragment UserProfile_public on User {
        id
        name
        bio
      }
    `,
    userRef
  );
  
  const privateData = useFragment(
    graphql`
      fragment UserProfile_private on User {
        email
        phone
        address
      }
    `,
    isOwner ? userRef : null  // Only fetch if owner!
  );
  
  return (
    <div>
      <h1>{publicData.name}</h1>
      <p>{publicData.bio}</p>
      {privateData && (
        <section className="private-info">
          <p>Email: {privateData.email}</p>
          <p>Phone: {privateData.phone}</p>
        </section>
      )}
    </div>
  );
}
Masking with GraphQL Scalars

Create custom scalars that automatically mask data:

scalar MaskedEmail
scalar MaskedPhone

type User {
  email: MaskedEmail!
  phone: MaskedPhone
}
const MaskedEmailScalar = new GraphQLScalarType({
  name: 'MaskedEmail',
  serialize(value) {
    const viewer = getCurrentViewer();
    if (!canViewEmail(viewer)) {
      return maskEmail(value);  // Returns "j***@example.com"
    }
    return value;
  }
});

Key Takeaways 🎯

πŸ“‹ Data Masking Quick Reference

Principle Implementation
πŸ”’ Server-First Mask data in resolvers, not UI code
⚠️ Nullable Fields Make maskable fields nullable in schema
🎭 Fragment Isolation Use fragments to enforce data boundaries
πŸ›‘οΈ Error Handling Return null for soft mask, throw for hard block
πŸ“ Audit Trails Log sensitive field access for compliance
πŸ” No Data Leaks Never expose masked data in errors or logs

πŸ’‘ Remember: Data masking is a server-side security concern. Relay's fragments provide excellent component isolation, but they're not a security boundaryβ€”the server must always enforce authorization.


Testing Your Masking πŸ§ͺ

Unit test resolvers with different viewer contexts:

test('email is masked for non-owners', async () => {
  const user = { id: '1', email: 'user@example.com' };
  const context = { currentUser: { id: '2' } };  // Different user
  
  const result = await resolvers.User.email(user, {}, context);
  
  expect(result).toBeNull();  // Masked!
});

test('email is visible to owner', async () => {
  const user = { id: '1', email: 'user@example.com' };
  const context = { currentUser: { id: '1' } };  // Same user
  
  const result = await resolvers.User.email(user, {}, context);
  
  expect(result).toBe('user@example.com');  // Visible!
});

Integration test to verify no data leaks:

test('unauthorized query does not leak data', async () => {
  const response = await graphql({
    schema,
    source: `
      query {
        user(id: "1") {
          email
        }
      }
    `,
    contextValue: { currentUser: { id: '2' } }
  });
  
  expect(response.data.user.email).toBeNull();
  expect(response.errors).toBeUndefined();  // No errors leaked
});

πŸ“š Further Study

  1. Relay Security Best Practices: https://relay.dev/docs/guides/security/
  2. GraphQL Authorization Patterns: https://www.apollographql.com/docs/apollo-server/security/authentication/
  3. OWASP GraphQL Security Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/GraphQL_Cheat_Sheet.html

Mastering data masking architecture in Relay means building applications where sensitive data never reaches unauthorized clients. This approachβ€”combining server-side authorization, schema design, and fragment-based isolationβ€”creates a robust security posture that scales with your application. πŸ”βœ¨