GraphQL Security Skill
Protect your GraphQL APIs from attacks
Overview
Learn essential security patterns for GraphQL: JWT authentication, role-based authorization, rate limiting, query complexity limits, and input validation.
Security Checklist
| Check | Priority | Implementation | |-------|----------|----------------| | Authentication | Critical | JWT with refresh tokens | | Authorization | Critical | Field-level with graphql-shield | | Rate Limiting | Critical | Per-user/IP with Redis | | Query Depth | High | graphql-depth-limit | | Query Complexity | High | graphql-query-complexity | | Introspection | High | Disable in production | | Input Validation | High | Validate all inputs | | Error Masking | Medium | Hide internal errors |
Core Patterns
1. JWT Authentication
import jwt from 'jsonwebtoken';
// Token creation
function createTokens(user) {
const accessToken = jwt.sign(
{ userId: user.id, roles: user.roles },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user.id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken };
}
// Context setup
const context = async ({ req }) => {
const token = req.headers.authorization?.replace('Bearer ', '');
let user = null;
if (token) {
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
user = await db.users.findById(payload.userId);
} catch (e) {
// Token invalid or expired
}
}
return { user };
};
// Login resolver
const resolvers = {
Mutation: {
login: async (_, { email, password }) => {
const user = await db.users.findByEmail(email);
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
throw new GraphQLError('Invalid credentials', {
extensions: { code: 'UNAUTHORIZED' }
});
}
return { ...createTokens(user), user };
},
},
};
2. Authorization with graphql-shield
import { rule, shield, and, or } from 'graphql-shield';
// Rules
const isAuthenticated = rule()((_, __, { user }) => user !== null);
const isAdmin = rule()((_, __, { user }) =>
user?.roles?.includes('ADMIN')
);
const isOwner = rule()(async (_, { id }, { user, dataSources }) => {
const resource = await dataSources.findById(id);
return resource?.userId === user?.id;
});
// Permissions
const permissions = shield({
Query: {
me: isAuthenticated,
users: and(isAuthenticated, isAdmin),
user: and(isAuthenticated, or(isOwner, isAdmin)),
},
Mutation: {
updateUser: and(isAuthenticated, or(isOwner, isAdmin)),
deleteUser: and(isAuthenticated, isAdmin),
},
User: {
email: or(isOwner, isAdmin),
privateField: isOwner,
},
}, {
fallbackError: new GraphQLError('Not authorized'),
});
// Apply
import { applyMiddleware } from 'graphql-middleware';
const protectedSchema = applyMiddleware(schema, permissions);
3. Rate Limiting
// Express-level (basic)
import rateLimit from 'express-rate-limit';
app.use('/graphql', rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
keyGenerator: (req) => req.user?.id || req.ip,
}));
// GraphQL-level (granular)
const typeDefs = gql`
directive @rateLimit(max: Int!, window: String!) on FIELD_DEFINITION
type Mutation {
login(email: String!, password: String!): AuthPayload!
@rateLimit(max: 5, window: "15m")
sendEmail(input: SendEmailInput!): Boolean!
@rateLimit(max: 10, window: "1h")
}
`;
4. Query Limits
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
// Max depth of 10
depthLimit(10),
// Max complexity of 1000
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 2,
listFactor: 10,
}),
],
// Disable introspection in production
introspection: process.env.NODE_ENV !== 'production',
});
5. Input Validation
import validator from 'validator';
import xss from 'xss';
const validate = {
email: (v) => {
if (!validator.isEmail(v)) throw new Error('Invalid email');
return validator.normalizeEmail(v);
},
password: (v) => {
if (v.length < 8) throw new Error('Password too short');
if (!/[A-Z]/.test(v)) throw new Error('Need uppercase');
if (!/[0-9]/.test(v)) throw new Error('Need number');
return v;
},
html: (v) => xss(v),
};
const resolvers = {
Mutation: {
createUser: async (_, { input }) => {
const clean = {
email: validate.email(input.email),
password: validate.password(input.password),
bio: input.bio ? validate.html(input.bio) : null,
};
return db.users.create(clean);
},
},
};
6. Error Masking
const server = new ApolloServer({
formatError: (error) => {
// Log full error
console.error(error);
// In production, hide internal errors
if (process.env.NODE_ENV === 'production') {
if (error.extensions?.code === 'INTERNAL_SERVER_ERROR') {
return { message: 'Internal error', extensions: { code: 'INTERNAL_ERROR' } };
}
}
return error;
},
});
Security Headers
import helmet from 'helmet';
import cors from 'cors';
app.use(helmet());
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(','),
credentials: true,
}));
app.use(express.json({ limit: '100kb' }));
Troubleshooting
| Issue | Cause | Solution | |-------|-------|----------| | Token always invalid | Clock skew | Add grace period | | Rate limit bypass | Wrong key | Use user ID when authenticated | | Auth not working | Context async | Await context setup | | Introspection exposed | Wrong env check | Verify NODE_ENV |
Security Testing
# Test introspection (should fail in prod)
curl -X POST $API \
-H "Content-Type: application/json" \
-d '{"query":"{ __schema { types { name } } }"}'
# Test rate limit
for i in {1..20}; do
curl -X POST $API \
-d '{"query":"mutation { login(email:\"x\",password:\"y\") { token } }"}'
done
# Test depth limit (should fail)
curl -X POST $API \
-d '{"query":"{ user { posts { author { posts { author { id } } } } } }"}'
Usage
Skill("graphql-security")
Related Skills
graphql-apollo-server- Server configurationgraphql-resolvers- Auth in resolversgraphql-schema-design- Auth-aware schema
Related Agent
06-graphql-security- For detailed guidance