Auth
GraphQL has no built-in authentication or authorization — the runtime trusts the resolvers. The patterns that work: authenticate at the HTTP layer (cookie, JWT, mTLS), put the user on the context, and let each resolver decide whether the requester is allowed to see the field. Field-level checks beat route-level checks because clients can compose any field combination they like.
Authentication + per-field authorization with context
EXAMPLE
// schema.graphql
scalar DateTime
type User {
id: ID!
email: String! # visible to the owner and admins
publicHandle: String! # visible to anyone
emailVerifiedAt: DateTime # owner + admins
}
type Mutation {
promoteToAdmin(userId: ID!): User! # admins only
}
type Query {
me: User # authenticated requesters
user(id: ID!): User # respects per-field rules
}
// --- context: who is making the request? ---
async function context({ req }) {
const auth = req.headers.authorization?.replace(/^Bearer /i, '');
if (!auth) return { user: null };
try {
const claims = verifyJwt(auth);
const user = await db.users.findById(claims.sub);
return { user };
} catch {
return { user: null };
}
}
// --- resolvers express access rules in code ---
const resolvers = {
Query: {
me: (_, __, { user }) => user ?? null,
user: (_, { id }) => db.users.findById(id),
},
Mutation: {
promoteToAdmin: async (_, { userId }, { user }) => {
requireAdmin(user);
return db.users.update(userId, { role: 'admin' });
},
},
User: {
// Field-level authorization. Same field, different visibility per requester.
email: (parent, _, { user }) => {
if (!user) return null;
if (user.id === parent.id || user.role === 'admin') return parent.email;
throw new ForbiddenError('email not visible to this requester');
},
emailVerifiedAt: (parent, _, { user }) => {
if (!user) return null;
if (user.id === parent.id || user.role === 'admin') return parent.emailVerifiedAt;
return null;
},
publicHandle: (parent) => parent.publicHandle,
},
};
// --- guards ---
class AuthError extends Error { extensions = { code: 'UNAUTHENTICATED' }; }
class ForbiddenError extends Error { extensions = { code: 'FORBIDDEN' }; }
function requireUser(user) { if (!user) throw new AuthError('login required'); }
function requireAdmin(user) { requireUser(user); if (user.role !== 'admin') throw new ForbiddenError('admin only'); }
// --- on the client, structure errors with extensions.code ---
// {
// "data": { "user": { "id": "u1", "publicHandle": "alice", "email": null } },
// "errors": [{ "message": "email not visible", "extensions": { "code": "FORBIDDEN" }, "path": ["user","email"] }]
// }
Why it matters
Field-level checks let you return partial results: the publicHandle resolves while the email field returns null + a structured error. Top-level "throw 403" wipes the entire response, which makes profile pages, search results, and admin panels harder to render correctly.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// Check ctx.user in resolvers, or use directives like @auth(role: ADMIN).Try it Yourself »
Discussion
Loading…