04 — Authentication & Authorization
한 줄 답: GraphQL에서 인증(누구냐?)은 transport 층(HTTP header)에서 context에 user를 주입하는 일이고, 권한(무엇을 할 수 있나?)은 필드 단위로 강제해야 한다. 셋 중 어디서 강제할지 — middleware vs directive vs library — 가 코드 가시성과 schema 가시성의 trade-off를 만든다.
Why — 왜 인증·권한을 분리하나
REST에서 둘은 자주 path 단위로 합쳐진다 — /admin/users라는 path가 둘 다를 암시. GraphQL은 endpoint가 하나이므로 분리가 명시적이어야 한다.
| 축 | 답하는 질문 | 위치 |
|---|---|---|
| Authentication (AuthN) | 누구냐? | HTTP middleware → context.user |
| Authorization (AuthZ) | 이 사용자가 이 필드에 권한 있나? | resolver / directive / library |
이 분리가 흐려지면 흔한 사고 두 가지가 난다.
| 안티패턴 | 사고 |
|---|---|
| AuthN만 끼우고 AuthZ를 빠뜨림 | 로그인한 사용자가 남의 데이터를 본다 (IDOR — Insecure Direct Object Reference) |
| AuthZ를 최상위 resolver에만 끼움 | user.email은 막혔지만 user.posts.author.email로 우회 노출 |
GraphQL의 resolver-per-field 구조는 권한 검사도 필드별로 짜야 한다는 뜻이다.
How — 어떻게 강제하나
1) AuthN — context에 user 주입
가장 표준적인 패턴.
// Apollo Server / Yoga 공통
const server = new ApolloServer({
schema,
context: async ({ req }) => {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return { user: null };
try {
const user = await verifyJWT(token); // 또는 session lookup
return { user };
} catch {
return { user: null };
}
},
});resolver 어디서든 context.user로 접근. 인증 실패 시 throw하지 않고 null로. 그 이유는 익명 쿼리 일부는 통과시키고, 권한 검사 단계에서 거절하기 위해서다 (예: viewer는 nullable, me는 non-null).
2) AuthZ 패턴 4가지
권한 강제 위치는 4가지 자리다. 각자 가시성-유연성 trade-off가 다르다.
| 패턴 | 어디에 권한 검사가 적히나 | 장점 | 단점 |
|---|---|---|---|
| (a) Resolver inline | 각 resolver 함수 안 | 가장 유연 — 임의 로직 | 분산 — 빠뜨리기 쉬움 |
(b) Directive @auth | schema의 type/field 옆 | Schema에 보임 | 정적 — 동적 조건은 한계 |
| (c) Middleware / shield | resolver 외부 wrapping | 코드 분리 | schema에 안 보임 |
| (d) External policy engine | OPA · casbin · oso | 정책이 별도 언어 | 호출 비용 |
(a) Resolver inline — 가장 기본
const resolvers = {
Query: {
user: async (_, { id }, ctx) => {
if (!ctx.user) throw new GraphQLError("UNAUTHENTICATED");
if (ctx.user.id !== id && !ctx.user.isAdmin)
throw new GraphQLError("FORBIDDEN");
return User.findById(id);
},
},
User: {
email: (parent, _, ctx) => {
if (ctx.user?.id !== parent.id && !ctx.user?.isAdmin) return null;
return parent.email;
},
},
};직관적이지만 민감 필드마다 빠뜨릴 위험. 큰 schema에서 coverage가 떨어진다.
(b) @auth directive — schema에 보이는 권한
directive @auth(requires: Role = USER) on FIELD_DEFINITION | OBJECT
enum Role { USER ADMIN }
type Query {
me: User @auth
adminPanel: AdminPanel @auth(requires: ADMIN)
}
type User {
id: ID!
name: String!
email: String @auth(requires: ADMIN)
internalNotes: String @auth(requires: ADMIN)
}schema 자체에 권한 정책이 보인다. introspection으로도 보이고 (켜져 있다면), 코드리뷰에서도 한눈에 잡힌다.
구현은 mapSchema (graphql-tools)로 schema를 변형한다.
import { mapSchema, MapperKind, getDirective } from "@graphql-tools/utils";
function authDirective(schema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const auth = getDirective(schema, fieldConfig, "auth")?.[0];
if (!auth) return;
const required = auth.requires ?? "USER";
const original = fieldConfig.resolve;
fieldConfig.resolve = async (src, args, ctx, info) => {
if (!ctx.user) throw new GraphQLError("UNAUTHENTICATED");
if (required === "ADMIN" && !ctx.user.isAdmin)
throw new GraphQLError("FORBIDDEN");
return original ? original(src, args, ctx, info) : src[info.fieldName];
};
return fieldConfig;
},
});
}→ Apollo Server, Yoga, Hot Chocolate 모두 directive를 first-class로 지원. AWS AppSync는 @aws_cognito_user_pools·@aws_iam 같은 공식 directive가 있다.
(c) graphql-shield — middleware로 분리
graphql-shield는 권한을 schema에서 분리해 코드 객체로 둔다.
import { shield, rule, and, or } from "graphql-shield";
const isAuthenticated = rule()((parent, args, ctx) => ctx.user !== null);
const isAdmin = rule()((parent, args, ctx) => ctx.user?.isAdmin === true);
const isSelf = rule()((parent, args, ctx) => ctx.user?.id === args.id);
const permissions = shield({
Query: {
me: isAuthenticated,
user: or(isSelf, isAdmin),
adminPanel: isAdmin,
},
User: {
email: or(isSelf, isAdmin),
},
}, { fallbackError: "FORBIDDEN" });
// schema에 middleware로 적용
import { applyMiddleware } from "graphql-middleware";
const protectedSchema = applyMiddleware(schema, permissions);장점 — 권한 로직이 한 파일에 모인다. coverage가 한눈에 보임. 단점 — schema에는 권한이 안 보인다. introspection으로도 안 보이고, schema 리뷰에선 코드와 같이 봐야 한다.
(d) 외부 policy engine — OPA / casbin / oso
복잡한 권한 (RBAC + ABAC + 멀티 테넌시) 에서는 별도 언어로 정책을 표현하는 게 낫다.
# OPA (Open Policy Agent) — Rego 언어
package graphql.authz
default allow = false
allow {
input.user.role == "admin"
}
allow {
input.field == "User.email"
input.user.id == input.parent.id
}// resolver에서 OPA 질의
async function checkPolicy(ctx, info, parent) {
const result = await fetch("http://opa:8181/v1/data/graphql/authz/allow", {
method: "POST",
body: JSON.stringify({
input: { user: ctx.user, field: `${info.parentType.name}.${info.fieldName}`, parent },
}),
}).then(r => r.json());
if (!result.result) throw new GraphQLError("FORBIDDEN");
}oso는 Polar라는 DSL을, casbin은 ACL/RBAC 모델을 정의한 후 라이브러리로 호출한다.
→ 외부 엔진의 장점 — 정책이 별도 git repo, 별도 팀이 관리, audit log 강함. 단점 — 호출 비용과 복잡도.
3) 패턴 선택 가이드
4) 핵심 함정 — list 필드에서의 권한
{ posts { id title author { email } } }posts는 통과해도 author.email은 사용자마다 다르다. N개 author 각각에 대해 권한 검사가 돌아야 한다 — N+1 권한 검사 문제. DataLoader와 결합해서 user permission도 batch로 가져오는 것이 정석.
const permissionLoader = new DataLoader(async (userIds) => {
return getPermissionsForUsers(userIds); // batch
});What — 구체 사양
@auth directive 매트릭스
| 서버 | 빌트인 directive | 외부 directive 정의 |
|---|---|---|
| Apollo Server | 없음 (직접 정의) | @graphql-tools/utils mapSchema |
| AWS AppSync | @aws_cognito_user_pools, @aws_iam, @aws_api_key, @aws_oidc | 빌트인 |
| Hasura | @auth_role (자동 생성) | metadata 기반 |
| Hot Chocolate (.NET) | [Authorize] (C# attribute) | @authorize SDL |
| Strawberry (Python) | @strawberry.permission_classes | data class |
graphql-shield 핵심 API
rule({ cache: "contextual" }) // request-scope cache
rule({ cache: "strict" }) // 같은 args면 1회만
and(rule1, rule2)
or(rule1, rule2)
not(rule)
chain(rule1, rule2) // 순차 (성능 위해)
race(rule1, rule2) // 병렬 (한 개 통과면 ok)cache가 중요한 이유 — DB lookup이 필요한 rule이 필드마다 반복 호출되면 N+1.
error code 컨벤션
| 상황 | code | HTTP 상응 |
|---|---|---|
| 인증 누락/실패 | UNAUTHENTICATED | 401 |
| 권한 부족 | FORBIDDEN | 403 |
| 리소스 없음 | NOT_FOUND | 404 |
| 입력 잘못 | BAD_USER_INPUT | 400 |
| 서버 에러 | INTERNAL_SERVER_ERROR | 500 |
throw new GraphQLError("Forbidden", {
extensions: { code: "FORBIDDEN", http: { status: 403 } },
});(GraphQL은 일반적으로 200 OK를 반환하지만, Apollo는 errors 안에 status를 noting할 수 있게 한다.)
Apollo의 @authenticated & @requiresScopes (Federation 2)
Federation 2부터 표준 directive가 추가됐다.
type Query {
me: User @authenticated
adminPanel: AdminPanel @requiresScopes(scopes: [["admin:read"]])
}router 단에서 검사 — gateway에서 막혀서 subgraph까지 안 간다. 즉 권한 검사가 Federation의 첫 hop에서 끝남.
What-if — 잘못 이해하면
1) 권한을 최상위에만 두면
→ Query.user에는 @auth가 있지만 Post.author.email은 열려 있다. relation 통과로 노출.
대응: 민감 필드는 type 단위에 @auth를 매기거나 shield의 User.email 룰을 두 곳에 모두 등록.
2) if (!ctx.user) return null로 조용히 막으면
→ 클라이언트는 권한 부족인지 데이터 없음인지 구분 못 한다. UX와 디버깅 모두 망가짐. 대응: throw로 명시. extensions.code로 분류.
3) Mutation에 권한을 안 매기면
→ Query에만 @auth 깔고 Mutation은 그대로 열어 두는 사고가 흔하다.
대응: Mutation은 디폴트로 deny, 명시적으로 공개 Mutation만 allow.
4) Permission lookup이 N+1을 부르면
→ 권한 검사가 DB 한 번씩. 100명의 user를 보면 100번 DB 조회. 대응: permission도 DataLoader로 batch.
5) @auth directive를 코드 변형 없이 정의만 하면
→ schema에는 있는데 실제 검사가 안 돈다. SDL만 적고 mapSchema를 안 한 사고.
대응: directive는 반드시 실행기가 필요. unit test로 검증.
6) Subscription에 권한이 없으면
→ subscription은 오랫동안 떠 있다. 권한이 연결 시점에만 검사되고 변경되어도 안 끊긴다. 대응: subscription은 connection 시점 + event마다 권한 재검사. 사용자 role 변경 시 강제 disconnect.
7) 외부 OPA/oso 호출이 resolver마다면
→ network round-trip이 N번. p99가 망가짐.
대응: OPA는 batch API 사용 (POST /v1/data with array input), 또는 embedded (Rego를 Go에 임베드).
Insight — 흥미로운 이야기
”GitHub의 @requireAdmin은 schema에 명시되어 있다”
GitHub v4의 SDL을 introspection으로 받아 보면 수십 개 directive가 명시되어 있다 — @requireAdmin, @preview (early access feature), @deprecated. directive는 GitHub 입장에서 문서이자 코드다. 외부 개발자가 어떤 필드에 권한이 필요한지를 introspection만으로 안다.
→ 교훈: directive의 진짜 가치는 실행이 아니라 공개다.
”AWS AppSync는 directive를 유일한 권한 방식으로 만들었다”
AWS AppSync는 코드 작성 없이 스키마와 directive만으로 권한이 끝난다.
type Post @aws_cognito_user_pools {
id: ID!
title: String!
internalNote: String @aws_iam # IAM 호출자만 (서버간)
}→ AppSync resolver는 VTL/JavaScript고, directive는 컴파일러가 검사. 코드에 권한이 새지 않는다 — 모든 권한이 schema와 IAM policy에만 산다.
→ 교훈: 권한을 코드 밖으로 빼면 audit이 schema diff로 가능해진다.
”graphql-shield의 ‘cache: contextual’은 왜 있나”
graphql-shield의 cache 옵션 contextual은 request-scope다 — 같은 user에 대해 같은 rule이 한 요청 안에서 여러 번 호출되어도 DB는 1번만 다녀온다. 이 한 옵션이 대규모 schema에서 graphql-shield를 쓸 수 있게 만든 결정타다. cache 없이 100개 필드에 rule을 깔면 100번의 권한 lookup.
→ 교훈: GraphQL 권한 검사는 resolver만큼 자주 호출된다. cache가 기능이 아니라 생존 조건.
”oso는 GitHub 권한 모델을 그대로 베꼈다”
oso의 예시 정책의 첫 페이지가 GitHub의 organization-repo-team-user 4-level 권한이다. 이 4-level은 Polar 10줄로 표현된다.
allow(user, "read", repo: Repository) if
role in user.roles and
role.repository = repo and
role.name in ["admin", "maintainer", "writer", "reader"];→ 같은 정책을 GraphQL resolver 안에 inline으로 짜면 수백 줄이 된다. 정책 언어의 가치는 표현력 압축에 있다.
→ 교훈: 권한이 데이터 모델만큼 복잡해지면 DSL이 답이다.
요약 + 다이어그램
인증은 context에 user 주입, 권한은 필드 단위 검사. 권한 위치 4 — resolver inline,
@authdirective, graphql-shield middleware, 외부 policy engine. directive는 schema에 보이고, middleware는 코드에 숨고, policy engine은 별도 언어에 산다. 다음 문서는 허용된 사용자가 너무 많이 보낼 때 — rate limit & cost.
다음 문서:
05-rate-limiting-and-cost-analysis.mdx— 허용된 사용자도 너무 많이 보내면 어떻게 막을까.