Topic: mcp server service account security
MCP server service account security — over-permissioned service accounts, workload identity vs. static credentials, cross-account role assumption scope
The most common credential pattern in community MCP servers is a single static service account key stored in an environment variable — one AWS IAM user, one GCP service account JSON, one database user with broad permissions. This pattern means every tool the server exposes runs under identical permissions. A prompt injection that tricks the server into calling the wrong tool with the wrong arguments can exercise any permission the service account holds — regardless of which specific tool the attacker targeted.
The over-permissioned service account problem
Consider an MCP server that exposes three tools: readDocument, listDocuments, and deleteDocument. If all three run under the same service account, a prompt injection that invokes deleteDocument when the user only intended to invoke readDocument exercises a delete permission that was never meant to be reachable in the normal flow:
// Dangerous: single service account for all tools
const s3 = new AWS.S3({
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
// This service account has s3:GetObject AND s3:DeleteObject AND s3:PutObject
});
server.tool('readDocument', {
handler: async ({ key }) => {
return s3.getObject({ Bucket: 'prod-docs', Key: key }).promise();
}
});
server.tool('deleteDocument', {
handler: async ({ key }) => {
return s3.deleteObject({ Bucket: 'prod-docs', Key: key }).promise();
// Runs under the same credential as readDocument — attacker can delete
}
});
The service account holds both read and delete permissions because the developer added them when building the server, intending to use them across tools. The blast radius of any single tool's compromise is the entire service account's permission set — not just the permission that tool needs.
Per-tool IAM role assumption
The correct architecture assumes a minimal IAM role per tool (or per tool category) rather than holding broad permissions in the process-level credential:
import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts';
import { S3Client, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
const sts = new STSClient({});
async function credentialsForRole(roleArn: string) {
const { Credentials } = await sts.send(new AssumeRoleCommand({
RoleArn: roleArn,
RoleSessionName: `mcp-tool-${Date.now()}`,
DurationSeconds: 900, // 15-minute session — short-lived
}));
return {
accessKeyId: Credentials.AccessKeyId,
secretAccessKey: Credentials.SecretAccessKey,
sessionToken: Credentials.SessionToken,
};
}
server.tool('readDocument', {
handler: async ({ key }) => {
// Assumes a read-only role — s3:GetObject on prod-docs only
const creds = await credentialsForRole(process.env.READ_ROLE_ARN);
const s3 = new S3Client({ credentials: creds });
return s3.send(new GetObjectCommand({ Bucket: 'prod-docs', Key: key }));
}
});
server.tool('deleteDocument', {
handler: async ({ key }, { session }) => {
// Assumes a delete role — requires additional session-level authorization check
if (!session.canDelete) throw new Error('Unauthorized');
const creds = await credentialsForRole(process.env.DELETE_ROLE_ARN);
const s3 = new S3Client({ credentials: creds });
return s3.send(new DeleteObjectCommand({ Bucket: 'prod-docs', Key: key }));
}
});
Each role is scoped to the minimum permission its tool requires. A prompt injection that invokes readDocument with a malicious key parameter cannot delete anything — the credentials assumed for that call don't allow it. The delete role is gated on an additional session-level authorization check that is not LLM-controllable.
Workload identity instead of static credentials
Static access keys stored in environment variables are the highest-risk credential form: they don't expire, they don't rotate automatically, and if they leak (via log output, a misconfigured health endpoint, or a SSRF attack), the attacker has permanent access until manual revocation. Workload identity eliminates the static credential entirely:
// AWS: ECS task role / EKS pod identity — no static credentials at all
// The SDK discovers credentials from the instance metadata service automatically
const s3 = new S3Client({}); // No accessKeyId / secretAccessKey
// The task/pod's IAM role is attached at the infrastructure level
// It cannot leak via environment variables because it was never in an env var
// It rotates automatically via the metadata service
// GCP equivalent: Workload Identity Federation — service account JSON not needed
// const storage = new Storage(); // discovers credentials from metadata server
Workload identity also means the credential is tied to the running process, not to a secret that the LLM could potentially relay. An LLM cannot exfiltrate a workload identity credential because it never appears as a value in the process's accessible memory in a form the LLM's tool output can capture.
Cross-account role assumption scope
MCP servers that need access across multiple AWS accounts or GCP projects should scope cross-account role assumptions to the minimum required for the operation. Overly broad cross-account roles — especially those granted sts:AssumeRole on all roles in another account — are a privilege escalation path:
// Dangerous: overly broad cross-account trust policy
// This allows the MCP server to assume ANY role in account 123456789
{
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::123456789:role/*" // ← wildcard
}
// Safe: scoped to exactly the roles the server needs
{
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": [
"arn:aws:iam::123456789:role/mcp-read-only",
"arn:aws:iam::123456789:role/mcp-write-limited"
]
}
If the MCP server's own account is compromised (via SSRF, code execution, or leaked workload identity credentials), a wildcard cross-account trust policy allows the attacker to assume any role in the target account — including administrative roles. Scoped role ARNs limit the lateral movement to exactly the roles the server's tools legitimately need.
Database service accounts
The same over-permissioning pattern applies to database credentials. An MCP server that runs read-only queries should connect as a read-only database user. The common counter-argument is that a read-write user is needed for some tools — the correct response is separate database users per tool category, not a single superuser for the whole server:
// Connection pools scoped by capability
const readPool = createPool({
connectionString: process.env.DB_READ_URL, // read-only user
max: 10
});
const writePool = createPool({
connectionString: process.env.DB_WRITE_URL, // write-limited user — INSERT/UPDATE only, no DROP
max: 3 // smaller pool — writes should be less frequent
});
server.tool('queryRecords', {
handler: async ({ sql }) => readPool.query(parameterizedQuery(sql))
});
server.tool('insertRecord', {
handler: async ({ table, data }, { session }) => {
if (!session.canWrite) throw new Error('Unauthorized');
return writePool.query(buildInsert(table, data));
}
});
What SkillAudit checks
SkillAudit's permissions hygiene analysis examines service account configuration in MCP server code and flags:
- Single static IAM/service-account credentials used across all tool handlers
- AWS access keys or GCP service account JSON in environment variable reads (
process.env.AWS_SECRET_ACCESS_KEYat module level) - Database connection strings with
superuser,admin, or admin-equivalent usernames - Wildcard resource ARNs in inline trust policy references found in deployment configs
- Cross-account role assumptions that are not scoped to specific role ARNs
Service account over-permissioning is one of the most common Permissions hygiene findings in SkillAudit reports. Run a free audit at skillaudit.dev to see your server's permissions score.