GraphQL Authentication and Authorization
Authentication asks "Who are you?" Authorization asks "What are you allowed to do?" Both are your responsibility as the API developer — GraphQL itself has no built-in security layer. You implement both through context and resolver logic.
Authentication vs Authorization — The Difference
Authentication Authorization ────────────── ───────────── Verifies identity Checks permissions "Prove you are Priya" "Priya can edit her posts only" Happens first Happens after authentication Fails → 401 Unauthorized Fails → 403 Forbidden Uses JWT, session cookie, API key Uses roles, permissions, ownership
Step 1 – Authenticate in Context
Context runs once per request before any resolver executes. This is the right place to verify the user's token and attach the user object to context. Every resolver then uses context.user to know who made the request.
import jwt from 'jsonwebtoken';
await startStandaloneServer(server, {
context: async ({ req }) => {
const token = req.headers.authorization?.replace('Bearer ', '');
let user = null;
if (token) {
try {
user = jwt.verify(token, process.env.JWT_SECRET);
} catch {
// Invalid token — user stays null (guest)
}
}
return { db: dbPool, user };
}
});
Step 2 – Authorize in Resolvers
Each resolver checks whether the authenticated user has permission to perform the requested operation. Throw a descriptive error when access is denied.
import { GraphQLError } from 'graphql';
const resolvers = {
Query: {
// Anyone can view products
products: () => db.products.findAll(),
// Must be logged in to see own orders
myOrders: (_, __, ctx) => {
if (!ctx.user) {
throw new GraphQLError('You must be logged in', {
extensions: { code: 'UNAUTHENTICATED' }
});
}
return db.orders.findByUser(ctx.user.id);
},
// Must be admin to see all orders
allOrders: (_, __, ctx) => {
if (!ctx.user) throw new GraphQLError('Not authenticated',
{ extensions: { code: 'UNAUTHENTICATED' } });
if (ctx.user.role !== 'ADMIN') throw new GraphQLError('Forbidden',
{ extensions: { code: 'FORBIDDEN' } });
return db.orders.findAll();
}
},
Mutation: {
// Can only edit your own post
editPost: async (_, args, ctx) => {
if (!ctx.user) throw new GraphQLError('Not authenticated',
{ extensions: { code: 'UNAUTHENTICATED' } });
const post = await db.posts.findById(args.id);
if (post.authorId !== ctx.user.id) {
throw new GraphQLError('You do not own this post',
{ extensions: { code: 'FORBIDDEN' } });
}
return db.posts.update(args.id, args.input);
}
}
};
Role-Based Access with a Helper
function requireRole(user, role) {
if (!user) throw new GraphQLError('Not authenticated',
{ extensions: { code: 'UNAUTHENTICATED' } });
if (user.role !== role) throw new GraphQLError('Forbidden',
{ extensions: { code: 'FORBIDDEN' } });
}
// Usage in resolvers:
adminReport: (_, __, ctx) => {
requireRole(ctx.user, 'ADMIN');
return db.reports.getAdmin();
}
Do Not Rely on Schema Shape for Security
Never assume a field is "hidden" because it is hard to guess. GraphQL introspection reveals all field names. Protect sensitive fields with resolver-level checks, not by hoping clients do not discover them.
✗ Wrong approach — "no one will guess this field name":
─────────────────────────────────────────────────────
type User {
secretAdminToken: String ← Anyone who introspects finds this
}
✓ Correct approach — resolver-level guard:
──────────────────────────────────────────
User: {
secretAdminToken: (user, _, ctx) => {
if (ctx.user?.role !== 'ADMIN') return null;
return user.secretAdminToken;
}
}
Key Points
- Verify the user's identity once in context — all resolvers share the resulting
context.userobject. - Enforce authorization inside each resolver that needs it, not at the schema level alone.
- Use
GraphQLErrorwith anextensions.codeofUNAUTHENTICATEDorFORBIDDENto signal the error type. - Never rely on field obscurity for security — introspection makes every field name visible.
