Jira 연동 플러그인 인증을 설계할 때 발견한 토큰 탈취 취약점
세션 기반 자동 로그인이 편리하다는 건 알았지만, 토큰 하나가 탈취되면 연결된 모든 서드파티 앱에 연쇄 접근이 가능하다는 걸 설계하다 발견했다.
Jira 연동 플러그인 인증을 설계할 때 발견한 토큰 탈취 취약점
상황
협업 SaaS에서 Jira·Confluence 연동 인앱 플러그인을 개발하면서 인증 구조를 설계할 일이 생겼습니다. 사용자가 한 번 Jira 계정을 연결해두면 자사 협업 플랫폼 안에서 바로 Jira 이슈를 조회·생성할 수 있는 기능이었습니다.
처음에 자연스럽게 떠오른 구조는 세션 기반 자동 로그인이었습니다. 한 번 OAuth로 Jira 토큰을 받아두면 세션에 저장하고, 이후에는 자동으로 재사용하는 방식입니다. 편의성 측면에서는 최선이었습니다.
설계를 진행하면서 한 가지 구조적인 문제가 눈에 들어왔습니다.
발견한 취약점
자사 협업 플랫폼 플랫폼은 Jira뿐 아니라 Confluence, GitHub, Slack 등 여러 서드파티 서비스와 연동됩니다. 각 서비스의 OAuth 토큰은 자사 협업 플랫폼 서버에서 사용자별로 관리됩니다.
연결 구조를 도식화하면 이렇습니다.
자사 협업 플랫폼 토큰
└─ 사용자 세션
├─ Jira OAuth 토큰
├─ Confluence OAuth 토큰
├─ GitHub OAuth 토큰
└─ Slack OAuth 토큰
세션 기반으로 자동 로그인이 동작한다는 건, 자사 협업 플랫폼 토큰 하나가 탈취되면 이 사용자에 연결된 모든 서드파티 앱 토큰에 연쇄적으로 접근이 가능하다는 의미입니다.
자사 협업 플랫폼 토큰 탈취 → Jira 이슈 전체 열람·수정, Confluence 문서 전체 접근, GitHub 레포 접근 가능.
자사 협업 플랫폼 자체의 보안 뿐 아니라, 연결된 외부 서비스의 데이터까지 한꺼번에 노출될 수 있는 구조였습니다.
왜 일반적인 OAuth 구조에서 이게 문제가 되나
OAuth 2.0 자체는 안전한 프로토콜입니다. 문제는 토큰을 어떻게 저장하고 재사용하느냐에서 생깁니다.
일반적인 OAuth 흐름은 이렇습니다.
1. 사용자가 "Jira 연결" 클릭
2. Jira OAuth 인증 페이지로 이동
3. 사용자가 권한 허용
4. Jira가 Authorization Code 반환
5. 서버에서 Code → Access Token 교환
6. Access Token을 DB에 저장
이후에 Jira API를 호출할 때마다 저장된 Access Token을 가져다 씁니다. 편리하지만, 이 토큰이 유출되거나 자사 협업 플랫폼 세션이 탈취되면 서버에 저장된 토큰을 통해 Jira에도 접근할 수 있습니다.
B2C 서비스에서는 이런 트레이드오프를 편의성 쪽으로 많이 허용합니다. 그런데 자사 서비스는 B2B 서비스였고, 고객사의 업무 데이터(이슈, 문서, 소스코드)가 노출되는 건 계약 위반 수준의 문제가 될 수 있었습니다.
팀 논의와 결정
이 구조적 취약점을 팀에 공유했습니다. 처음에는 "그게 실제로 발생할 가능성이 얼마나 되냐"는 반응도 있었습니다.
논의한 내용을 정리하면:
- 세션 탈취 가능성: CSRF, XSS, 세션 하이재킹 등 세션 기반 공격은 꾸준히 발생합니다. "가능성이 낮다"는 말은 위험이 없다는 뜻이 아닙니다.
- 피해 범위: 자사 협업 플랫폼 계정 탈취만으로 끝나지 않고 연결된 모든 서드파티 서비스로 확대됩니다. 피해 범위가 너무 큽니다.
- 고객사 신뢰: B2B 환경에서 한 번의 보안 사고는 계약 해지로 이어질 수 있습니다.
결론적으로 편의성을 일부 양보하고 보안을 우선하는 방향으로 합의했습니다.
선택한 구조: 민감 작업 구간 재인증 강제
모든 Jira API 호출에 재인증을 요구하면 사용성이 크게 떨어집니다. 조회(Read) 작업과 변경(Write) 작업을 구분하는 방식으로 접근했습니다.
[읽기 작업] Jira 이슈 조회, Confluence 문서 열람
→ 저장된 토큰으로 자동 처리 (편의성 유지)
[쓰기 작업] 이슈 생성·수정·삭제, 댓글 작성
→ 자사 협업 플랫폼 재인증 강제
쓰기 작업 전에 자사 협업 플랫폼 인증을 한 번 더 요구합니다. 토큰이 탈취된 상태에서 공격자가 단순 열람은 할 수 있지만, 데이터를 변경하려면 실제 자사 협업 플랫폼 계정 인증이 추가로 필요합니다.
// 인증 미들웨어
async function requireReauth(req, res, next) {
const { action } = req.body;
// 쓰기 작업 여부 확인
const writingActions = ['create', 'update', 'delete', 'comment'];
const isWriteAction = writingActions.includes(action);
if (isWriteAction) {
// 자사 협업 플랫폼 재인증 토큰 검증
const reauthToken = req.headers['x-swit-reauth'];
if (!reauthToken) {
return res.status(401).json({
code: 'REAUTH_REQUIRED',
message: '이 작업을 수행하려면 자사 협업 플랫폼 재인증이 필요합니다.',
});
}
const isValid = await switAuthService.verifyReauthToken(reauthToken);
if (!isValid) {
return res.status(401).json({ code: 'INVALID_REAUTH_TOKEN' });
}
}
next();
}프론트엔드에서는 쓰기 작업을 시도할 때 자사 협업 플랫폼 인증 모달을 띄우고, 인증 완료 후 받은 일회성 토큰을 헤더에 담아 API를 호출하는 방식으로 구현했습니다.
토큰 만료 처리
또 하나 해결해야 할 문제가 있었습니다. Jira OAuth 토큰의 만료 처리입니다.
초기에 토큰 만료 후 재인증이 정상 동작하지 않아, 사용자가 오류 화면만 보고 어떻게 해야 할지 몰랐습니다. 토큰 만료 시 자동으로 재인증 플로우로 연결되도록 처리했습니다.
// Jira API 호출 래퍼
async function callJiraApi(userId, apiCall) {
try {
const token = await tokenStore.getToken(userId, 'jira');
return await apiCall(token);
} catch (error) {
if (error.status === 401) {
// 토큰 만료 → 재인증 요청
await tokenStore.invalidateToken(userId, 'jira');
throw new TokenExpiredError('Jira 토큰이 만료됐습니다. 다시 연결해주세요.');
}
throw error;
}
}// 프론트엔드에서 TokenExpiredError 처리
try {
const issues = await fetchJiraIssues();
} catch (error) {
if (error.code === 'TOKEN_EXPIRED') {
// 재연결 안내 모달 표시
showReconnectModal('Jira 연결이 만료됐습니다. 다시 연결해주세요.');
}
}결과
이 구조로 얻은 것들을 정리하면:
- 토큰 연쇄 탈취 취약점 해소: 자사 협업 플랫폼 토큰이 탈취돼도 서드파티 쓰기 작업은 차단됩니다
- 피해 범위 제한: 최악의 경우에도 읽기 접근으로 피해가 한정됩니다
- 사용성 균형: 일반 조회는 자동, 민감한 작업만 재인증으로 타협했습니다
- 고객사 신뢰: B2B 환경에서 "우리 플랫폼은 이 취약점을 알고 설계로 막았다"고 말할 수 있게 됐습니다
마치며
보안과 편의성은 항상 트레이드오프 관계입니다. "더 안전하게 하면 된다"는 말은 맞지만, 사용하기 너무 불편하면 사람들이 우회합니다.
이 경험에서 배운 건 설계 단계에서 취약점을 발견할 수 있다는 것입니다. 코드를 다 짜고 나서 보안 감사를 받는 것보다, 설계 과정에서 "이 흐름이 어떻게 공격될 수 있나"를 먼저 생각하는 게 훨씬 낫습니다.
구조를 그려보면 보입니다. 자사 협업 플랫폼 토큰 → 모든 서드파티 토큰으로 이어지는 화살표를 도식화했을 때 비로소 "이게 하나가 뚫리면 다 뚫리는 구조구나"가 눈에 들어왔습니다.