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
- Relay Security Best Practices: https://relay.dev/docs/guides/security/
- GraphQL Authorization Patterns: https://www.apollographql.com/docs/apollo-server/security/authentication/
- 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. πβ¨