Guide · AWS AppSync · Authorization
AppSync Authorization Modes for MCP Servers — API Key, Cognito, IAM, Lambda Authorizer
AppSync supports five authorization modes that can be mixed on a single API: API key, Amazon Cognito User Pools, AWS IAM SigV4, Lambda authorizer, and OpenID Connect (OIDC). For MCP server APIs the typical setup is: API key as the default (for unauthenticated status reads and subscription connections), with Cognito User Pools as the secondary mode for authenticated mutations (claim-your-server, configure-alerts). Server-to-server calls from an MCP gateway that needs to write tool results use IAM SigV4. The two critical design constraints: (1) each GraphQL field can advertise which auth modes it accepts using @aws_api_key, @aws_cognito_user_pools, @aws_iam, @aws_lambda, or @aws_oidc directives — a field without any directive uses only the API's default auth mode; (2) subscription fields do NOT inherit auth from the mutations they subscribe to — each subscription field must explicitly declare its allowed auth modes or callers will receive 401 on connection.
TL;DR
Set one default auth mode and up to four additional modes in the AppSync API configuration. Annotate fields with @aws_api_key, @aws_cognito_user_pools, @aws_iam, etc. to declare which callers can reach each field. For Lambda authorizer: return { isAuthorized: true, resolverContext: {…}, ttlOverride: 300 } — the resolverContext is available inside resolvers as ctx.identity.resolverContext. Cognito auth puts JWT claims at ctx.identity.claims; IAM auth puts the role ARN at ctx.identity.userArn. Subscription fields must carry their own auth directive independently of the linked mutation.
Multi-auth configuration and field-level directives
When an AppSync API has multiple auth modes, each field's @aws_* directives control which modes can access it. A field with no directive is accessible only via the default auth mode. A caller using Cognito tokens who calls a field that only has @aws_api_key will receive a 401 even with a valid Cognito token — the directive must match the caller's auth mode.
type Query {
# Readable by anyone with an API key (public status feed)
listMcpServers(limit: Int, nextToken: String): ServerConnection
@aws_api_key
# Readable by API key (public) and Cognito users (authenticated)
getMcpServerStatus(serverId: ID!): ServerStatus
@aws_api_key @aws_cognito_user_pools
# Readable only by Cognito users (private monitoring data)
getMyServerAlerts(serverId: ID!): [Alert]
@aws_cognito_user_pools
# Server-to-server: Lambda gateway calls this with IAM SigV4
getInternalMetrics(teamId: ID!): Metrics
@aws_iam
}
type Mutation {
# Requires Cognito auth — updates server config
claimMcpServer(serverId: ID!, verificationToken: String!): Server
@aws_cognito_user_pools
# IAM for internal gateway writing tool results
recordToolInvocation(input: ToolInvocationInput!): ToolResult
@aws_iam
# Lambda authorizer — validates a custom API token
configureWebhookAlert(serverId: ID!, webhookUrl: String!): Alert
@aws_lambda
}
type Subscription {
# IMPORTANT: must declare auth independently — does NOT inherit from createToolResult mutation
onServerStatusChange(serverId: ID!): ServerStatus
@aws_subscribe(mutations: ["updateServerStatus"])
@aws_api_key @aws_cognito_user_pools
}
The auth evaluation order for multi-auth APIs: AppSync checks if the caller's auth mode is listed on the field. If yes, the resolver runs. If no, AppSync returns a 401 without calling any resolver. Resolver-level auth (checking claims inside the resolver) is a second layer on top — use pipeline resolver function 1 for ownership checks after AppSync's auth-mode gate has passed.
Lambda authorizer: response shape and resolver context
AppSync Lambda authorizers run as a separate Lambda (not the same Lambda as a data source resolver). The function receives an authorization event and must return an authorization response. The resolverContext field is forwarded into every resolver that runs under this authorization — it appears at ctx.identity.resolverContext and can carry decoded token claims, team IDs, or permission scopes without resolver code needing to re-verify the token.
// Lambda authorizer handler — separate from tool Lambda
// Triggered before ANY resolver runs for the authorized request
export const handler = async (event) => {
// event.authorizationToken — the raw token from the Authorization header
// event.requestContext.apiId — the AppSync API ID
// event.requestContext.accountId — AWS account ID
// event.requestContext.queryString — the GraphQL query being executed
// event.requestContext.variables — GraphQL variables
// event.requestContext.requestId — request trace ID
const { authorizationToken } = event;
if (!authorizationToken || !authorizationToken.startsWith('Bearer ')) {
return { isAuthorized: false };
}
let claims;
try {
claims = await verifyApiToken(authorizationToken.replace('Bearer ', ''));
} catch (err) {
return { isAuthorized: false };
}
return {
isAuthorized: true,
// resolverContext is available inside any resolver as ctx.identity.resolverContext
resolverContext: {
teamId: claims.teamId,
userId: claims.sub,
plan: claims.plan, // "free" | "author" | "team" | "enterprise"
privateEndpointIds: claims.endpoints ?? []
},
// Cache this authorization result for 5 minutes
// During TTL window, AppSync does NOT re-invoke this Lambda
ttlOverride: 300
};
};
// Inside a resolver, access resolverContext:
// export function request(ctx) {
// const { teamId, plan } = ctx.identity.resolverContext;
// if (plan === 'free' && ctx.args.private) {
// util.error("Upgrade required", "PlanLimitError");
// }
// ...
// }
The ttlOverride field controls how long AppSync caches the authorization response for the same token. A value of 300 means AppSync caches the result for 5 minutes — subsequent requests with the same token within that window skip the Lambda invocation entirely. Set ttlOverride: 0 to disable caching (useful during development or when tokens carry fine-grained per-request scopes). The maximum TTL is 3600 seconds.
Cognito User Pools: claims, groups, and ownership patterns
When the caller authenticates with a Cognito access token or ID token, AppSync puts the full decoded JWT claims at ctx.identity.claims and the Cognito groups the user belongs to at ctx.identity.cognitoGroups. The user's sub (UUID) is at ctx.identity.sub. This is sufficient for most ownership checks inside resolvers.
// Resolver function that enforces ownership using Cognito claims
export function request(ctx) {
return {
operation: "GetItem",
key: {
serverId: util.dynamodb.toDynamoDB(ctx.args.serverId)
}
};
}
export function response(ctx) {
const server = ctx.result;
if (!server) {
util.error("Server not found", "NotFoundError");
}
// Ownership check: server.ownerId must match the Cognito sub
const callerId = ctx.identity.sub;
const isOwner = server.ownerId === callerId;
// Group-based access: 'admin' group can access any server
const isAdmin = (ctx.identity.cognitoGroups ?? []).includes('admin');
if (!isOwner && !isAdmin) {
util.error("Access denied", "AuthorizationError");
}
return server;
}
// ctx.identity shape for Cognito auth:
// {
// sub: "user-uuid",
// issuer: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXX",
// username: "johndoe",
// claims: {
// sub: "user-uuid",
// email: "john@example.com",
// "cognito:groups": ["authors", "team-abc"],
// // ... all JWT claims
// },
// cognitoGroups: ["authors", "team-abc"],
// defaultAuthStrategy: "ALLOW"
// }
IAM SigV4: server-to-server MCP gateway calls
IAM auth is the right choice for an MCP proxy gateway (e.g., an ECS service) that writes tool invocation records to AppSync without a user session. The gateway assumes an IAM role with appsync:GraphQL permission and signs requests with SigV4. AppSync puts the IAM identity at ctx.identity.userArn and the account ID at ctx.identity.accountId.
// IAM policy for the MCP gateway role
// Attach this to the ECS task role that calls AppSync
{
"Effect": "Allow",
"Action": "appsync:GraphQL",
"Resource": [
"arn:aws:appsync:us-east-1:123456789:apis/APPSYNC_API_ID/types/Mutation/fields/recordToolInvocation",
"arn:aws:appsync:us-east-1:123456789:apis/APPSYNC_API_ID/types/Query/fields/getInternalMetrics"
]
}
// Use * wildcard on Resource to allow all fields (development only)
// Signing AppSync requests with IAM in Node.js (using aws4)
import aws4 from 'aws4';
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
const creds = await fromNodeProviderChain()();
const query = `
mutation RecordToolInvocation($input: ToolInvocationInput!) {
recordToolInvocation(input: $input) {
toolCallId
status
durationMs
}
}
`;
const body = JSON.stringify({
query,
variables: { input: { toolName: "search_files", sessionId: "ses-123", durationMs: 450, status: "complete" } }
});
const signed = aws4.sign({
host: 'XXXXXXXX.appsync-api.us-east-1.amazonaws.com',
path: '/graphql',
service: 'appsync',
region: 'us-east-1',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body
}, {
accessKeyId: creds.accessKeyId,
secretAccessKey: creds.secretAccessKey,
sessionToken: creds.sessionToken
});
const response = await fetch(`https://${signed.host}${signed.path}`, {
method: 'POST',
headers: signed.headers,
body
});
Failure modes reference
| Failure | Symptom | Fix |
|---|---|---|
| Subscription field missing auth directive | All subscription connection attempts return 401 Unauthorized | Add @aws_api_key, @aws_cognito_user_pools, etc. to the subscription field — auth is NOT inherited from the linked mutation |
| Lambda authorizer not configured as additional auth mode | @aws_lambda directive on a field causes schema deployment failure | Add Lambda authorizer as an additional auth mode in the AppSync API settings (CDK: additionalAuthorizationModes: [{ authorizationType: AuthorizationType.LAMBDA, lambdaAuthorizerConfig: {…} }]) |
Lambda authorizer ttlOverride: 0 in production | Lambda authorizer invoked on every request — high latency, Lambda throttle risk | Use ttlOverride: 300 (5 min) minimum; for high-traffic APIs, use longer TTL and design token claims to not require per-request freshness |
Cognito group check on ctx.identity.claims["cognito:groups"] | Claims field not present; group check always fails | Use ctx.identity.cognitoGroups (AppSync resolves this from the token automatically) rather than the raw JWT claim key |
IAM role missing appsync:GraphQL on specific field ARN | IAM-authorized calls return UnauthorizedException even with valid SigV4 signature | Grant appsync:GraphQL on the specific field ARN or use */types/*/fields/* wildcard; field ARN format: arn:aws:appsync:REGION:ACCOUNT:apis/API_ID/types/TYPE/fields/FIELD |
| Multi-auth API but default mode not matching caller token type | Callers using a non-default auth mode get 401 even on fields with the correct directive | The default auth mode is used when no auth directive is on a field; fields with directives accept those modes regardless of default — verify the field has the correct directive for the caller's auth type |
| OIDC token issuer mismatch | OIDC-authorized calls return 401 with "invalid issuer" | The oidcConfig.oidcIssuerUrl on the AppSync API must exactly match the iss claim in the JWT, including trailing slash consistency |