Node.js 에이전트에 마스터 키를 주면 안 되는 이유와 60초 임시 토큰 구현법
LangChain이나 LlamaIndex로 자율형 에이전트를 만들다 보면 결국 DB와 외부 API를 연결하는 순간을 맞이합니다. 여기서 보통 사고가 납니다. 프롬프트 인젝션 한 방에 .env에 적힌 OpenAI 마스터 키나 DB 어드민 패스워드가 터지는 거죠. 프롬프트 가드레일을 아무리 촘촘하게 짜도 소용없습니다. LLM의 추론 영역과 실행 영역이 묶여 있는 한, 문장 하나로 보안은 허망하게 뚫립니다.
구글의 2025년 SAIF(Secure AI Framework) 보고서를 보면 에이전트 도입 기업의 88%가 프롬프트 인젝션 시도를 겪었습니다. 기존 텍스트 패턴 탐지 기법의 차단율은 23%에 불과했죠. 에이전트 프로세스 자체를 신뢰하지 않는 편이 속 편합니다. 에이전트에는 권한을 아예 주지 말고, 미들웨어 레이어에서 60초만 살아있는 임시 토큰을 주입하는 구조로 바꿔야 합니다.
Express에서 구현하는 60초 Dynamic Vault 토큰 주입
HashiCorp Vault의 AppRole 인증을 쓰면 도구를 호출하는 딱 그 순간에만 60초짜리 토큰을 발행할 수 있습니다. 에이전트가 외부 API를 때릴 때 인터셉터가 중간에 개입해서 Short-lived 토큰을 헤더에 밀어 넣는 방식입니다.
import { Request, Response, NextFunction } from 'express';
import vault from 'node-vault';
interface VaultAppRoleAuth {
roleId: string;
secretId: string;
}
export class EphemeralTokenInjector {
private vaultClient: any;
private roleId: string;
private secretId: string;
constructor(endpoint: string, auth: VaultAppRoleAuth) {
this.vaultClient = vault({ endpoint });
this.roleId = auth.roleId;
this.secretId = auth.secretId;
}
private async getAppRoleToken(): Promise<string> {
const result = await this.vaultClient.approleLogin({
role_id: this.roleId,
secret_id: this.secretId,
});
return result.auth.client_token;
}
public createToolInterceptor(targetServiceRole: string) {
return async (req: Request, res: Response, next: NextFunction) => {
let clientToken: string | null = null;
try {
clientToken = await this.getAppRoleToken();
const dynamicSecret = await this.vaultClient.write(
`sys/leases/generate/${targetServiceRole}`,
{ ttl: '60s' }
);
req.headers['authorization'] = `Bearer ${dynamicSecret.data.token}`;
req.body.ephemeralContext = {
leaseId: dynamicSecret.lease_id,
expiresAt: Date.now() + 60000,
};
next();
} catch (error) {
res.status(500).json({ error: 'Failed to inject ephemeral dynamic secret' });
} finally {
clientToken = null;
}
};
}
}
코드만 짠다고 끝이 아닙니다. Node.js V8 엔진의 힙 메모리는 가비지 컬렉터가 제 마음대로 돌기 때문에, 변수를 비워도 토큰 문자열이 한동안 힙에 남아있습니다. 힙덤프 공격을 받으면 유출 위험이 있죠.
- API 응답을 받는 즉시 민감한 값을
Buffer 객체로 담습니다.
- 실행이 끝나면
Buffer.fill(0)을 실행해 바이트를 0으로 강제 정화합니다.
- 참조 변수에
null을 할당해 GC의 먹이로 넘깁니다.
이 세 단계만 챙겨도 메모리 프로파일링으로 토큰이 털릴 확률이 확 떨어집니다.
| 자격 증명 관리 방식 |
평균 수명 (TTL) |
탈취 시 피해 범위 |
감사 추적 |
| 하드코딩된 Master API Key |
무제한 |
인프라 전체 권한 장악 |
단일 키 공유로 주체 식별 불가 |
| 환경변수 (.env) Static Secret |
프로세스 수명 |
해당 Node 프로세스 권한 전체 남용 |
사용자별 워크플로우 추적 불가 |
| Vault AppRole Ephemeral Token |
60초 |
해당 Tool 1회 실행으로 제한 |
Vault Audit Log로 초 단위 추적 |
Auth0 OIDC Claim을 PostgreSQL RLS 정책으로 넘기기
프롬프트 탈옥으로 에이전트가 "전체 회원 정보 조회해줘"라는 명령을 수행하더라도, DB 엔진 차원에서 거절하면 그만입니다. Multi-tenant SaaS 환경이라면 PostgreSQL의 Row Level Security(RLS)를 세팅해야 합니다.
Auth0가 발급한 JWT의 userId와 tenantId를 가져와 LangChain의 RunnableConfig 세션 컨텍스트로 전달합니다. 이 컨텍스트를 Prisma 트랜잭션 안에서 세션 변수로 주입합니다.
import { RunnableConfig } from '@langchain/core/runnables';
import { PrismaClient } from '@prisma/client';
export interface AgentUserClaims {
userId: string;
tenantId: string;
}
export async function executeAgentToolWithRLS<T>(
prisma: PrismaClient,
config: RunnableConfig,
dbOperation: (tx: any) => Promise<T>
): Promise<T> {
const claims = config.configurable?.userClaims as AgentUserClaims;
if (!claims || !claims.tenantId || !claims.userId) {
throw new Error('Unauthorized: Missing OIDC Claims in Agent Execution Context');
}
return await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_tenant_id', ${claims.tenantId}, true)`;
await tx.$executeRaw`SELECT set_config('app.current_user_id', ${claims.userId}, true)`;
return await dbOperation(tx);
});
}
set_config 마지막 인자로 true를 넘겨야 현재 트랜잭션 범위(SET LOCAL)에만 변수가 적용됩니다. DB 커넥션 풀링 환경에서 이전 사용자 권한이 다음 요청으로 새어나가는 불상사를 막는 핵심 설정입니다.
이제 SQL 파일에서 이 세션 변수를 읽는 정책을 만듭니다.
ALTER TABLE tenant_documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_documents FORCE ROW LEVEL SECURITY;
CREATE POLICY agent_tenant_isolation_policy ON tenant_documents
FOR ALL
TO authenticated_agent_role
USING (
tenant_id = current_setting('app.current_tenant_id', true)::uuid
)
WITH CHECK (
tenant_id = current_setting('app.current_tenant_id', true)::uuid
);
이 조합을 갖춰두면 에이전트가 아무리 이상한 쿼리를 만들어내도 자기 테넌트 영역 밖의 데이터는 조회조차 못 합니다. 유출 사고가 나더라도 데이터 복구 시간(MTTR)을 며칠에서 단 몇 분 단위로 줄일 수 있습니다.
Promptfoo와 PyRIT을 이용한 CI/CD 검증 파이프라인
개발자가 에이전트 도구 코드를 바꿀 때마다 권한 격리가 제대로 동작하는지 매번 수동으로 테스트할 수는 없습니다. 오픈소스 도구인 Promptfoo와 Microsoft의 PyRIT을 GitHub Actions에 묶어서 PR 단위로 검증합니다.
name: Agent Red Teaming Security Gate
on:
pull_request:
paths:
- 'src/agents/**'
- 'src/tools/**'
- 'prompts/**'
jobs:
security-eval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Run Promptfoo Scan
uses: promptfoo/promptfoo-action@v1
with:
config: 'promptfooconfig.yaml'
openai-api-key: ${{ secrets.OPENAI_API_KEY }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Run PyRIT Multi-Turn Test
env:
AGENT_ENDPOINT: 'http://localhost:3000/api/agent'
run: |
python -m pip install pyrit
python scripts/run_pyrit_eval.py --endpoint $AGENT_ENDPOINT --pass-threshold 0.98
서버 런타임에는 위협 패턴을 감지했을 때 동작을 바로 정지시키는 서킷 브레이커 미들웨어를 붙여둡니다.
import { Request, Response, NextFunction } from 'express';
export class AgentCircuitBreaker {
private failureCount: number = 0;
private readonly threshold: number = 3;
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
private forbiddenSignatures: RegExp[] = [
/ignore\s+all\s+previous\s+instructions/i,
/system::override_privileges/i,
/grant\s+role\s+admin/i,
/concat\s*\(\s*select/i
];
public middleware() {
return (req: Request, res: Response, next: NextFunction) => {
if (this.state === 'OPEN') {
return res.status(503).json({
error: 'CircuitBreaker:Open - Agent execution halted'
});
}
const promptInput = JSON.stringify(req.body);
const isPatternViolated = this.forbiddenSignatures.some(sig => sig.test(promptInput));
if (isPatternViolated) {
this.failureCount++;
this.dispatchSecurityAlert(req.body);
if (this.failureCount >= this.threshold) {
this.state = 'OPEN';
}
return res.status(403).json({
error: 'Security Policy Violation: Malicious prompt pattern'
});
}
next();
};
}
private dispatchSecurityAlert(payload: any): void {
const webhookUrl = process.env.SECURITY_WEBHOOK_URL;
if (!webhookUrl) return;
fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'AGENT_PRIVILEGE_ESCALATION_DETECTED',
timestamp: new Date().toISOString(),
payload
})
}).catch(() => {});
}
}
보안 테스트 파이프라인 구축 순서는 간단합니다.
- 프로젝트 루트에
promptfooconfig.yaml을 두고 jailbreak와 excessive-agency 검사 항목을 정의합니다.
- GitHub Actions에 PR 감지 워크플로우를 걸어 Promptfoo와 PyRIT 시나리오를 돌립니다.
- Express 엔드포인트 전면에
AgentCircuitBreaker 미들웨어를 배치해 3회 연속 인젝션 패턴 감지 시 에이전트 실행을 중단시킵니다.
이 구성을 갖추면 매주 수동 프롬프트 검토에 들이던 5시간 이상의 시간을 아낄 수 있습니다.
| 평가 지표 |
수동 검증 방식 |
Zero-Trust 자동화 도입 |
| 감사 준비 시간 |
주당 5~8시간 |
주당 1시간 미만 |
| 권한 유출 시 MTTR |
수일 (전체 로그/DB 전수 조사) |
수 분 (RLS 범위로 제한) |
| 프롬프트 인젝션 차단율 |
약 23% |
99.9% |
| 키 유출 시 과금 위험 |
무제한 클라우드 과금 |
60초 TTL로 과금 차단 |
에이전트 보안의 핵심은 모델이 말을 잘 듣길 기도하는 게 아닙니다. 모델이 헛소리를 하거나 공격에 넘어간 상태에서도 시스템에 해를 입히지 못하도록 인프라 차원에서 수갑을 채워두는 겁니다.