블로그 자동화 토큰을 권한별 Credential로 바꾼 이유
하나의 정적 토큰을 원문 비저장, scope, 만료, 폐기와 감사 로그를 갖춘 Credential로 바꾸고 초안 작성과 공개 발행 권한을 분리한 과정입니다.
블로그 글을 자동으로 작성하는 도구를 만들면서 처음에는 하나의 정적 API 토큰으로 초안 저장과 공개 발행을 모두 처리했습니다. 구현은 간단했지만 토큰을 가진 도구라면 누구나 글을 즉시 공개할 수 있었고, 토큰이 언제 사용됐는지 확인하거나 개별 도구만 폐기할 방법도 없었습니다.
개인 블로그라도 외부 에이전트와 자동화를 연결하는 순간 인증은 운영 기능이 됩니다. 이번 작업에서는 하나의 공유 토큰을 권한·만료·폐기·감사 기록을 가진 Credential로 바꾸고, 원문 토큰을 DB에 저장하지 않도록 구성했습니다.
하나의 토큰은 권한 경계를 만들 수 없었다
자동화의 역할은 서로 달랐습니다.
- Git 이력을 읽고 글 초안만 저장하는 도구
- 검토된 글을 공개하는 도구
- 공개 경력 정보를 읽는 도구
- 비공개 경력으로 이력서를 구성하는 도구
정적 토큰 하나를 공유하면 가장 강한 권한에 모든 도구가 맞춰집니다. 초안 작성기에도 발행 권한이 생기고, 한 도구의 토큰이 노출되면 전체 자동화를 교체해야 합니다.
권한을 다음과 같이 작업 단위로 나눴습니다.
export const API_CREDENTIAL_SCOPES = [
"career:public:read",
"career:private:read",
"content:draft:write",
"content:publish",
"resume:compose",
"resume:render",
] as const;
초안 작성 에이전트에는 career:public:read와 content:draft:write만 부여할 수 있습니다. 공개 발행은 별도 Credential을 사용하거나 필요할 때만 권한을 추가합니다.
원문 토큰은 발급 순간 한 번만 보여줬다
Credential을 만들 때 식별 가능한 prefix와 충분히 긴 임의 값을 조합했습니다. DB에는 토큰 원문 대신 SHA-256 해시만 저장합니다.
const identifier = randomBytes(9).toString("base64url");
const tokenPrefix = `pb_${identifier}`;
const token = `${tokenPrefix}.${randomBytes(32).toString("base64url")}`;
await prisma.apiCredential.create({
data: {
name: input.name,
tokenPrefix,
tokenHash: hashToken(token),
scopes: input.scopes,
expiresAt,
},
});
원문 토큰은 발급 응답에서 한 번만 보여주고 이후 목록에는 prefix만 표시합니다. DB가 노출돼도 저장된 해시만으로 API를 호출할 수 없게 하기 위한 선택입니다.
prefix는 전체 Credential을 순회하지 않고 후보 한 건을 찾는 용도입니다. 찾은 뒤 요청 토큰을 다시 해시해 저장된 값과 비교했습니다.
비교 시간도 인증 정보가 되지 않게 했다
문자열을 일반 비교하면 어느 위치에서 값이 달랐는지에 따라 처리 시간이 달라질 수 있습니다. 해시 비교에는 길이를 먼저 확인한 뒤 timingSafeEqual을 사용했습니다.
function tokensMatch(left: string, right: string) {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return (
leftBuffer.length === rightBuffer.length &&
timingSafeEqual(leftBuffer, rightBuffer)
);
}
이것만으로 모든 타이밍 공격을 제거한다고 단정할 수는 없지만, 비밀값 비교에 일반 문자열 연산을 사용하지 않는 기본 원칙을 적용했습니다.
인증과 권한 검사를 분리했다
요청 처리 과정은 두 단계로 나눴습니다.
flowchart LR
A[Bearer 토큰] --> B[Credential 인증]
B --> C{활성 상태인가?}
C -->|아니오| D[401과 거부 기록]
C -->|예| E{필요 scope가 있는가?}
E -->|아니오| F[403과 거부 기록]
E -->|예| G[요청 처리와 사용 기록]인증 단계에서는 토큰 존재, prefix 형식, 해시 일치, 만료, 폐기 상태를 확인합니다. 권한 단계에서는 해당 API가 요구하는 scope를 확인합니다.
예를 들어 글 생성 API는 요청 상태에 따라 필요한 권한이 달라집니다.
const requiredScope =
requestedStatus === "published"
? "content:publish"
: "content:draft:write";
같은 엔드포인트를 호출하더라도 초안 저장과 공개 발행의 위험도가 다르다는 점을 권한 모델에 반영했습니다.
폐기와 사용 기록을 하나의 트랜잭션으로 확인했다
인증 직후 관리자가 Credential을 폐기할 수 있습니다. 단순히 먼저 조회하고 나중에 사용 시각을 기록하면 그 사이의 상태 변경을 놓칠 수 있습니다.
권한 확인 뒤 사용 기록을 남길 때, 아직 폐기되지 않았고 만료되지 않은 Credential만 조건부 업데이트했습니다. 업데이트된 행이 있을 때만 감사 로그를 같은 DB 트랜잭션에 기록했습니다.
const active = await prisma.$transaction(async (transaction) => {
const updated = await transaction.apiCredential.updateMany({
where: {
id: credentialId,
revokedAt: null,
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
data: { lastUsedAt: now },
});
if (!updated.count) return false;
await transaction.apiCredentialAuditLog.create({
data: { credentialId, event: "used", scope, method, path },
});
return true;
});
이 구조는 폐기와 요청 처리가 겹칠 때 오래된 조회 결과만 믿고 요청을 허용할 가능성을 줄입니다.
감사 로그에는 비밀값을 남기지 않았다
감사 로그는 발급, 사용, 거부, 폐기 이벤트와 scope, HTTP method, 경로를 기록합니다. 토큰 원문, 요청 본문, 글 내용은 기록하지 않습니다.
관리 화면에서는 다음 정보만 확인할 수 있습니다.
- Credential 이름과 식별 prefix
- 부여된 scope
- 만료 시각과 마지막 사용 시각
- 폐기 상태
- 발급·사용·거부 이력
누가 어떤 토큰 원문을 갖고 있는지는 서버가 다시 보여줄 수 없습니다. 분실했다면 기존 Credential을 폐기하고 새로 발급해야 합니다.
인증만 추가하고 끝내지 않았다
외부 API가 생기면서 입력과 요청량도 별도로 방어했습니다.
Content-Length와 실제 스트림 바이트를 모두 확인하는 본문 크기 제한- 제목, slug, 설명, 태그, 게시 날짜의 서버 검증
- IP 기반 요청 제한
- 위험한 Markdown 링크 차단
- 내부 오류 메시지를 일반화한 API 응답
- 보안 헤더와 운영 Nginx 설정 보강
본문 크기는 헤더만 신뢰하지 않고 스트림을 읽는 동안 누적 바이트를 확인했습니다. 클라이언트가 Content-Length를 생략하거나 잘못 보낸 경우에도 제한을 적용하기 위해서입니다.
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel();
return { ok: false, error: "payload_too_large" };
}
}
레거시 토큰은 명시적으로만 허용했다
기존 자동화를 한 번에 모두 바꾸면 운영 중인 글 작성 흐름이 끊길 수 있습니다. 정적 토큰 호환 경로는 유지하되, ALLOW_LEGACY_BLOG_API_TOKEN=true일 때만 동작하도록 했습니다.
기본값은 새 Credential이고 레거시 사용은 운영자가 의도적으로 켜야 합니다. 호환성은 제공하지만 새 구조를 우회하는 영구 백도어가 되지 않도록 한 것입니다.
확인된 결과와 한계
Credential 발급·관리·API 권한 검증과 기본 보안 방어는 개인 블로그 운영 환경에 배포됐습니다. 실제로 초안 작성과 공개 발행은 서로 다른 scope를 통해 처리됩니다.
운영 환경에서는 다음과 같이 Credential의 권한과 수명을 관리합니다.
- 원문 토큰 대신 prefix와 해시를 저장합니다.
- Credential마다 scope, 만료, 폐기 상태를 관리합니다.
- 발급·사용·거부·폐기 이력을 남깁니다.
- 초안 작성과 공개 발행 권한을 분리합니다.
- 요청 크기와 빈도를 제한하고 서버 입력을 검증합니다.
현재 요청 제한은 단일 프로세스 메모리에 저장되므로 여러 인스턴스가 동시에 동작하는 환경에서는 전역 한도를 보장하지 않습니다. 서버가 수평 확장된다면 Redis 같은 공유 저장소로 옮겨야 합니다. SHA-256 해시는 충분히 긴 무작위 토큰을 전제로 하며, 사람이 정한 짧은 비밀번호 저장 방식으로 재사용하면 안 됩니다.
자동화 Credential은 작은 사용자 계정처럼 다뤄야 한다
이번 작업에서 남은 기준은 다음과 같습니다.
- 도구마다 필요한 최소 권한만 발급합니다.
- 원문 토큰은 한 번만 보여주고 서버에는 해시만 저장합니다.
- 만료와 폐기를 인증 과정과 사용 기록 시점에 모두 확인합니다.
- 비밀값 없이도 조사할 수 있는 감사 로그를 남깁니다.
- 레거시 호환은 명시적인 플래그 뒤에 둡니다.
API 토큰은 단순한 문자열이 아니라 권한과 수명을 가진 자동화 계정입니다. 이 관점으로 바꾸자 초안 작성기와 발행 도구를 같은 비밀값에 묶지 않을 수 있었습니다.