n8n 셀프 호스팅에 사내 SSO 연동하기: 인증 게이트웨이 구축 완벽 가이드
n8n을 사내에 도입하면서 직원들이 별도 계정 없이 사번/비밀번호로 로그인할 수 있게 만들었습니다. Enterprise 라이선스 없이 Community Edition에서 인증 게이트웨이를 구축한 과정을 공유합니다.
1. 문제 상황
1.1 n8n을 사내에 도입하고 싶은데...
n8n은 강력한 워크플로우 자동화 도구입니다. 셀프 호스팅이 가능해서 사내 인프라에 직접 설치할 수 있죠. 하지만 한 가지 문제가 있습니다.
문제: 사내 구성원들이 n8n을 사용하려면 별도의 계정을 만들어야 한다
회사에는 이미 통합 인증 시스템이 있습니다. 직원들은 사번과 비밀번호로 모든 사내 시스템에 로그인합니다. 그런데 n8n만 별도 계정을 만들어야 한다면?
- IT 관리자: 계정 생성/삭제를 수동으로 관리해야 함
- 직원: 또 다른 비밀번호를 기억해야 함
- 보안팀: 퇴사자 계정 관리가 이중으로 필요
1.2 n8n Enterprise vs Community
n8n Enterprise 버전은 SAML, LDAP, OIDC 등 다양한 SSO 옵션을 제공합니다. 하지만 라이선스 비용이 발생하죠.
| 기능 | Community | Enterprise |
|---|---|---|
| SAML SSO | ❌ | ✅ |
| LDAP | ❌ | ✅ |
| OIDC | ❌ | ✅ |
| 가격 | 무료 | 유료 |
우리 회사의 상황:
- 사내 로그인 API가 이미 존재 (REST API 방식)
- n8n Community Edition 사용 중
- Enterprise 라이선스 없이 SSO 연동 필요
1.3 사내 로그인 API 스펙
# 사내 로그인 API 호출 예시
response = requests.post(
"http://internal-auth-server:8090/login",
data={"username": "사번", "password": "비밀번호"},
headers={"Content-Type": "application/x-www-form-urlencoded"}
)
# 응답
{
"name": "홍길동",
"username": "12345",
"email": "[email protected]",
"department": "DX추진팀",
"section": "AI혁신본부",
"token": "access-token-xxx"
}
이 API를 활용해서 n8n 로그인을 연동하는 것이 목표입니다.
2. 해결 방안 설계
2.1 아키텍처 설계
┌─────────────────────────────────────────────────────────────────┐
│ nginx (reverse proxy) │
│ https://company.com/workflow/ │
└─────────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Auth Gateway (Node.js) │
│ localhost:3080 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ /login │ │ /api/login │ │ /* (proxy to n8n) │ │
│ │ 로그인 UI │ │ 인증 처리 │ │ n8n 요청 프록시 │ │
│ └─────────────┘ └──────┬──────┘ └─────────────────────────┘ │
└──────────────────────────┼──────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ 사내 Auth │ │ PostgreSQL │ │ n8n │
│ Server │ │ (n8n DB) │ │ :6031 │
└────────────┘ └────────────┘ └────────────┘
2.2 인증 흐름
1. 사용자 → /login 접속 (사번/비밀번호 입력)
↓
2. Auth Gateway → 사내 API 호출 → 사용자 정보 획득
↓
3. PostgreSQL에서 이메일로 n8n 사용자 조회
↓
┌────────────────┴────────────────┐
↓ ↓
기존 사용자 신규 사용자
↓ ↓
비밀번호 업데이트 DB에 직접 INSERT
↓ ↓
└────────────────┬────────────────┘
↓
4. n8n /rest/login 호출 → 세션 쿠키 획득
↓
5. 쿠키 설정 후 n8n 메인 화면으로 리다이렉트
2.3 핵심 설계 결정
왜 프록시 방식인가?
처음에는 단순히 로그인 페이지만 만들고, 로그인 후 n8n URL로 리다이렉트하려 했습니다.
문제: 쿠키 도메인 불일치
- 로그인: localhost:3080
- n8n: localhost:6031
- 쿠키가 다른 포트로 전달되지 않음
해결책: Auth Gateway가 n8n을 완전히 프록시
모든 요청 → localhost:3080 → (내부) n8n:6031
- 같은 origin이므로 쿠키 문제 해결
- n8n 포트를 외부에 노출하지 않아 보안 향상
- /signin 요청을 /login으로 자동 리다이렉트 가능
3. 구현
3.1 프로젝트 구조
n8n-auth-gateway/
├── Dockerfile
├── package.json
└── src/
├── index.js # Express 서버 + 프록시
├── auth.js # 인증 로직 + DB 연동
└── public/
└── login.html # 로그인 UI
3.2 package.json
{
"name": "n8n-auth-gateway",
"version": "1.0.0",
"dependencies": {
"express": "^4.18.2",
"axios": "^1.6.0",
"cookie-parser": "^1.4.6",
"http-proxy-middleware": "^3.0.0", // ← n8n 프록시용
"pg": "^8.11.3", // ← PostgreSQL 직접 연결
"bcryptjs": "^2.4.3", // ← 비밀번호 해싱
"uuid": "^9.0.0"
}
}
3.3 Express 서버 (index.js)
const express = require('express');
const cookieParser = require('cookie-parser');
const { createProxyMiddleware } = require('http-proxy-middleware');
const path = require('path');
const auth = require('./auth');
const app = express();
const PORT = process.env.PORT || 3080;
const N8N_INTERNAL_URL = process.env.N8N_INTERNAL_URL || 'http://n8n:6031';
app.use(cookieParser());
// ① n8n의 /signin, /signout을 우리 로그인 페이지로 리다이렉트
app.get('/signin', (req, res) => {
res.redirect('/login');
});
app.get('/signout', (req, res) => {
res.redirect('/login');
});
// ② 로그인 페이지 - 이미 로그인된 상태면 메인으로 리다이렉트
app.get('/login', (req, res) => {
const n8nAuthCookie = req.cookies['n8n-auth'];
if (n8nAuthCookie) {
return res.redirect('/'); // ← 이미 로그인됨
}
res.sendFile(path.join(__dirname, 'public', 'login.html'));
});
// ③ 로그인 API
app.post('/api/login', express.json(), async (req, res) => {
const { username, password } = req.body;
try {
const result = await auth.authenticate(username, password);
if (result.success) {
// n8n 세션 쿠키 설정
res.cookie('n8n-auth', result.sessionToken, {
httpOnly: true,
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000, // 24시간
path: '/',
});
return res.json({
success: true,
user: result.user,
redirectUrl: '/', // ← 같은 origin이므로 /로 충분
});
} else {
return res.status(401).json({
success: false,
error: result.error,
});
}
} catch (error) {
console.error('Login error:', error.message);
return res.status(500).json({
success: false,
error: '서버 오류가 발생했습니다.',
});
}
});
// ④ 나머지 모든 요청은 n8n으로 프록시
const n8nProxy = createProxyMiddleware({
target: N8N_INTERNAL_URL,
changeOrigin: true,
ws: true, // ← WebSocket 지원 (n8n 실시간 업데이트)
onProxyReq: (proxyReq, req, res) => {
// 쿠키를 n8n으로 전달
if (req.cookies['n8n-auth']) {
proxyReq.setHeader('Cookie', `n8n-auth=${req.cookies['n8n-auth']}`);
}
},
onProxyRes: (proxyRes, req, res) => {
// n8n이 /signin으로 리다이렉트하면 /login으로 변경
const location = proxyRes.headers['location'];
if (location && location.includes('/signin')) {
proxyRes.headers['location'] = '/login';
}
},
});
app.use('/', n8nProxy);
app.listen(PORT, () => {
console.log(`Auth Gateway running on port ${PORT}`);
});
핵심 포인트:
/signin,/signout→/login리다이렉트로 n8n 기본 로그인 페이지 우회- 이미 로그인된 상태에서
/login접근 시 메인으로 리다이렉트 http-proxy-middleware로 모든 n8n 요청 프록시ws: true로 WebSocket 지원 (워크플로우 실행 상태 실시간 업데이트)
3.4 인증 로직 (auth.js)
const axios = require('axios');
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const { v4: uuidv4 } = require('uuid');
const { Pool } = require('pg');
const INTERNAL_AUTH_URL = process.env.INTERNAL_AUTH_URL;
const N8N_INTERNAL_URL = process.env.N8N_INTERNAL_URL;
const PASSWORD_SECRET = process.env.PASSWORD_SECRET;
// PostgreSQL 연결 (n8n 데이터베이스)
const pool = new Pool({
host: process.env.POSTGRES_HOST || 'postgres',
port: process.env.POSTGRES_PORT || 5432,
database: process.env.POSTGRES_DB || 'n8n',
user: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD,
});
// 이메일 도메인 별칭 (마이그레이션용)
const EMAIL_DOMAIN_ALIASES = ['company.com', 'company.co.kr'];
3.4.1 n8n 호환 비밀번호 생성
n8n은 비밀번호에 대한 복잡성 요구사항이 있습니다:
- 8자 이상
- 대문자, 소문자, 숫자, 특수문자 포함
사내 비밀번호를 그대로 사용할 수 없으므로, 사번 기반으로 n8n용 비밀번호를 생성합니다:
/**
* 사번으로 n8n 비밀번호 생성
* - 사용자가 이 비밀번호를 알 필요 없음 (게이트웨이가 대신 로그인)
* - 동일 사번 → 항상 동일한 비밀번호 (결정적)
*/
function generateN8nPassword(employeeId) {
const hash = crypto
.createHmac('sha256', PASSWORD_SECRET)
.update(employeeId)
.digest('hex')
.substring(0, 12);
// n8n 비밀번호 규칙 충족을 위한 접미사
return `${hash}!Aa1`; // ← 특수문자, 대문자, 소문자, 숫자 포함
}
// 예시: 사번 "12345" → "a1b2c3d4e5f6!Aa1"
3.4.2 기존 사용자 조회 (이메일 도메인 매핑)
기존에 [email protected]로 가입한 사용자가 있는데, 사내 API는 [email protected]을 반환하는 경우가 있습니다. 이를 처리하기 위한 도메인 별칭 매핑:
/**
* 이메일 도메인 변형 생성
* [email protected] → [[email protected], [email protected]]
*/
function getEmailVariants(email) {
const [localPart, domain] = email.split('@');
const variants = [email.toLowerCase()];
for (const altDomain of EMAIL_DOMAIN_ALIASES) {
if (altDomain.toLowerCase() !== domain.toLowerCase()) {
variants.push(`${localPart}@${altDomain}`.toLowerCase());
}
}
return variants;
}
/**
* n8n 사용자 조회 (모든 도메인 변형 검색)
*/
async function findN8nUser(email) {
const variants = getEmailVariants(email);
const placeholders = variants.map((_, i) => `LOWER($${i + 1})`).join(', ');
const query = `
SELECT id, email, "firstName", "lastName", password, "roleSlug", "createdAt"
FROM "user"
WHERE LOWER(email) IN (${placeholders})
LIMIT 1
`;
const result = await pool.query(query, variants);
return result.rows[0] || null;
}
3.4.3 신규 사용자 생성 + Personal Project
n8n 사용자를 DB에 직접 생성할 때, Personal Project도 함께 생성해야 합니다. 이것이 없으면 n8n 대시보드에서 404 에러가 발생합니다.
/**
* Personal Project 생성
* - n8n에서 각 사용자는 자신만의 프로젝트 공간이 필요
*/
async function createPersonalProject(userId, userInfo) {
const projectId = generateProjectId(); // 16자 랜덤 ID
const projectName = `${userInfo.name} ${userInfo.department} <${userInfo.email}>`;
const client = await pool.connect();
try {
await client.query('BEGIN');
// Project 생성
await client.query(`
INSERT INTO project (id, name, type, "createdAt", "updatedAt")
VALUES ($1, $2, 'personal', NOW(), NOW())
`, [projectId, projectName]);
// Project-User 관계 생성
await client.query(`
INSERT INTO project_relation ("projectId", "userId", role, "createdAt", "updatedAt")
VALUES ($1, $2, 'project:personalOwner', NOW(), NOW())
`, [projectId, userId]);
await client.query('COMMIT');
return projectId;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* n8n 사용자 생성 (트랜잭션)
*/
async function createN8nUser(userInfo, plainPassword) {
const userId = uuidv4();
const hashedPassword = await bcrypt.hash(plainPassword, 10);
const client = await pool.connect();
try {
await client.query('BEGIN');
// User 생성
await client.query(`
INSERT INTO "user" (
id, email, "firstName", "lastName", password, "roleSlug",
"createdAt", "updatedAt", disabled, "mfaEnabled"
) VALUES ($1, $2, $3, $4, $5, 'global:member', NOW(), NOW(), false, false)
`, [userId, userInfo.email, userInfo.name, userInfo.department, hashedPassword]);
// Personal Project 생성 (필수!)
await createPersonalProject(userId, userInfo);
await client.query('COMMIT');
return { id: userId, email: userInfo.email };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
3.4.4 기존 사용자 마이그레이션
기존에 n8n에 직접 가입한 사용자도 사내 로그인으로 전환할 수 있습니다:
/**
* 기존 사용자 비밀번호 업데이트 (마이그레이션)
*/
async function updateN8nUserPassword(userId, plainPassword) {
const hashedPassword = await bcrypt.hash(plainPassword, 10);
await pool.query(`
UPDATE "user"
SET password = $1, "updatedAt" = NOW()
WHERE id = $2
`, [hashedPassword, userId]);
}
/**
* Personal Project 없는 기존 사용자를 위한 보정
*/
async function ensurePersonalProject(userId, userInfo) {
const query = `
SELECT p.id FROM project p
JOIN project_relation pr ON p.id = pr."projectId"
WHERE pr."userId" = $1 AND p.type = 'personal'
LIMIT 1
`;
const result = await pool.query(query, [userId]);
if (result.rows.length === 0) {
console.log(`Creating missing personal project for: ${userInfo.email}`);
await createPersonalProject(userId, userInfo);
}
}
3.4.5 메인 인증 흐름
/**
* 메인 인증 함수
*/
async function authenticate(username, password) {
// Step 1: 사내 API로 인증
let internalUser;
try {
const response = await axios.post(
INTERNAL_AUTH_URL,
`username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`,
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
internalUser = response.data;
if (!internalUser || !internalUser.email) {
return { success: false, error: '사번 또는 비밀번호가 올바르지 않습니다.' };
}
} catch (error) {
if (error.response?.status === 401) {
return { success: false, error: '사번 또는 비밀번호가 올바르지 않습니다.' };
}
return { success: false, error: '인증 서버에 연결할 수 없습니다.' };
}
// Step 2: n8n 비밀번호 생성
const n8nPassword = generateN8nPassword(username);
// Step 3: n8n 사용자 조회 또는 생성
let n8nUser = await findN8nUser(internalUser.email);
if (!n8nUser) {
// 신규 사용자 생성
n8nUser = await createN8nUser({
email: internalUser.email,
name: internalUser.name,
department: internalUser.department,
}, n8nPassword);
} else {
// 기존 사용자 - 비밀번호 동기화 + 정보 업데이트
await updateN8nUserPassword(n8nUser.id, n8nPassword);
await ensurePersonalProject(n8nUser.id, internalUser);
}
// Step 4: n8n 로그인하여 세션 토큰 획득
const sessionToken = await loginToN8n(n8nUser.email, n8nPassword);
if (!sessionToken) {
return { success: false, error: 'n8n 로그인에 실패했습니다.' };
}
return {
success: true,
sessionToken,
user: {
name: internalUser.name,
email: n8nUser.email,
department: internalUser.department,
},
};
}
/**
* n8n 내부 로그인 API 호출
*/
async function loginToN8n(email, password) {
try {
const response = await axios.post(
`${N8N_INTERNAL_URL}/rest/login`,
{ emailOrLdapLoginId: email, password }, // ← n8n 로그인 API 필드명 주의!
{ headers: { 'Content-Type': 'application/json' } }
);
// Set-Cookie 헤더에서 세션 토큰 추출
const cookies = response.headers['set-cookie'];
if (cookies) {
const sessionCookie = cookies.find(c => c.startsWith('n8n-auth='));
if (sessionCookie) {
return sessionCookie.split(';')[0].split('=')[1];
}
}
return null;
} catch (error) {
console.error('n8n login error:', error.message);
return null;
}
}
3.5 로그인 UI (login.html)
토스 스타일의 미니멀한 디자인으로 구현:
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>n8n 로그인</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Pretendard', sans-serif;
background: #f5f6f7;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
background: #fff;
border-radius: 20px;
width: 100%;
max-width: 400px;
padding: 40px 32px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.logo { text-align: center; margin-bottom: 40px; }
.logo h1 { font-size: 22px; color: #191f28; }
.logo p { color: #8b95a1; margin-top: 8px; font-size: 15px; }
.form-group { margin-bottom: 16px; }
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333d4b;
font-size: 14px;
}
.form-group input {
width: 100%;
padding: 16px;
border: 1px solid #e5e8eb;
border-radius: 12px;
font-size: 16px;
background: #f9fafb;
transition: all 0.2s;
}
.form-group input:focus {
border-color: #3182f6;
background: #fff;
box-shadow: 0 0 0 3px rgba(49, 130, 246, 0.1);
outline: none;
}
.btn-login {
width: 100%;
padding: 16px;
background: #3182f6;
color: white;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-login:hover { background: #1b64da; }
.btn-login:disabled { background: #a8cafc; cursor: not-allowed; }
.alert {
padding: 14px 16px;
border-radius: 12px;
margin-bottom: 20px;
font-size: 14px;
display: none;
}
.alert.error { background: #fff5f5; color: #e03131; }
.alert.success { background: #e8f7e8; color: #0ca678; }
.alert.show { display: block; }
</style>
</head>
<body>
<div class="login-container">
<div class="logo">
<h1>n8n 로그인</h1>
<p>사내 계정으로 로그인하세요</p>
</div>
<div id="alert" class="alert"></div>
<form id="loginForm">
<div class="form-group">
<label for="username">사번</label>
<input type="text" id="username" placeholder="사번을 입력하세요" required>
</div>
<div class="form-group">
<label for="password">비밀번호</label>
<input type="password" id="password" placeholder="비밀번호를 입력하세요" required>
</div>
<button type="submit" class="btn-login" id="loginBtn">로그인</button>
</form>
</div>
<script>
const form = document.getElementById('loginForm');
const alert = document.getElementById('alert');
const loginBtn = document.getElementById('loginBtn');
form.addEventListener('submit', async (e) => {
e.preventDefault();
loginBtn.disabled = true;
alert.className = 'alert';
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (data.success) {
alert.textContent = '로그인 성공! 잠시 후 이동합니다.';
alert.className = 'alert success show';
setTimeout(() => {
window.location.href = data.redirectUrl || '/';
}, 1000);
} else {
alert.textContent = data.error || '로그인에 실패했습니다.';
alert.className = 'alert error show';
loginBtn.disabled = false;
}
} catch (error) {
alert.textContent = '서버에 연결할 수 없습니다.';
alert.className = 'alert error show';
loginBtn.disabled = false;
}
});
</script>
</body>
</html>
3.6 Docker 설정
Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY src ./src
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 -G nodejs && \
chown -R nodejs:nodejs /app
USER nodejs
EXPOSE 3080
CMD ["npm", "start"]
docker-compose.yml
services:
n8n:
image: n8nio/n8n:latest
# ports:
# - 6031:6031 # ← 외부 노출 제거! auth-gateway만 접근
environment:
- N8N_PORT=6031
# ... 기타 설정
n8n-auth-gateway:
build:
context: ./n8n-auth-gateway
dockerfile: Dockerfile
ports:
- "3080:3080" # ← 이것만 외부 노출
environment:
- PORT=3080
- INTERNAL_AUTH_URL=http://internal-auth:8090/login
- N8N_INTERNAL_URL=http://n8n:6031
- PASSWORD_SECRET=${N8N_AUTH_PASSWORD_SECRET}
- POSTGRES_HOST=postgres
- POSTGRES_DB=${POSTGRES_DB}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
depends_on:
n8n:
condition: service_healthy
postgres:
condition: service_healthy
3.7 nginx 설정
# /workflow/ 경로로 n8n 접근
location ^~ /workflow/ {
proxy_pass http://127.0.0.1:3080/; # ← auth-gateway로 프록시
# 기본 프록시 헤더
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 지원 (n8n 실시간 업데이트)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# 정적 파일 (캐싱 최적화)
location ^~ /workflow/assets/ {
proxy_pass http://127.0.0.1:3080/assets/;
expires 1d;
}
location ^~ /workflow/static/ {
proxy_pass http://127.0.0.1:3080/static/;
expires 1d;
}
# Socket.IO (실시간 통신)
location ^~ /workflow/socket.io/ {
proxy_pass http://127.0.0.1:3080/socket.io/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
4. 핵심 개념 정리
| 개념 | 설명 |
|---|---|
| 인증 게이트웨이 | 사내 인증과 n8n 사이의 중개 서버. 프록시 역할도 수행 |
| n8n-auth 쿠키 | n8n 세션 토큰. 이 쿠키가 있어야 n8n API 호출 가능 |
| Personal Project | n8n에서 각 사용자의 개인 작업 공간. 없으면 대시보드 404 에러 |
| roleSlug | n8n 권한 (global:owner, global:admin, global:member) |
| http-proxy-middleware | Express에서 리버스 프록시 기능 구현하는 라이브러리 |
5. 문제 해결 (Troubleshooting)
5.1 "column role does not exist" 에러
-- n8n 버전에 따라 컬럼명이 다름
-- 구버전: role
-- 신버전: roleSlug
SELECT id, email, "roleSlug" FROM "user"; -- ← roleSlug 사용
5.2 로그인 후 "Could not find a personal project" 에러
// 사용자 생성 시 Personal Project도 함께 생성해야 함
await createN8nUser(...);
await createPersonalProject(userId, userInfo); // ← 이것이 빠지면 에러!
5.3 n8n 로그인 API 400 에러
// 필드명이 email이 아니라 emailOrLdapLoginId
axios.post('/rest/login', {
emailOrLdapLoginId: email, // ← 이 필드명 사용!
password: password
});
5.4 쿠키가 전달되지 않음
// 쿠키 설정 시 path: '/' 필수
res.cookie('n8n-auth', token, {
path: '/', // ← 이것이 없으면 특정 경로에서만 쿠키 전송
httpOnly: true,
sameSite: 'lax',
});
5.5 WebSocket 연결 실패
# nginx에 WebSocket 설정 필요
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
6. 베스트 프랙티스
체크리스트
- [ ]
PASSWORD_SECRET을 환경변수로 분리하고 안전하게 관리 - [ ] n8n 포트(6031)를 외부에 노출하지 않음
- [ ] 로그인 시도 횟수 제한 (rate limiting) 적용
- [ ] HTTPS 환경에서
secure: true쿠키 설정 - [ ] 기존 사용자 이메일 도메인 매핑 설정
- [ ] Personal Project 생성 로직 포함
보안 고려사항
// 1. Rate Limiting
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 10, // 최대 10회
});
app.post('/api/login', loginLimiter, ...);
// 2. 비밀번호 시크릿 관리
const PASSWORD_SECRET = process.env.PASSWORD_SECRET;
// 절대 하드코딩하지 않음!
// 3. HTTPS 환경에서 Secure 쿠키
res.cookie('n8n-auth', token, {
secure: process.env.NODE_ENV === 'production',
// ...
});
7. FAQ
Q: n8n Enterprise를 사용하면 이 모든 것이 필요 없나요?
A: 네, Enterprise 버전은 SAML, LDAP, OIDC를 기본 지원합니다. 하지만 Community Edition에서 커스텀 REST API 인증이 필요한 경우 이 방법이 유효합니다.
Q: 기존 n8n 사용자의 워크플로우는 어떻게 되나요?
A: 이메일이 일치하면 기존 사용자로 인식되어 워크플로우가 그대로 유지됩니다. 비밀번호만 동기화됩니다.
Q: 사내 인증 서버가 다운되면 n8n도 사용 불가인가요?
A: 새로 로그인할 때만 사내 인증이 필요합니다. 이미 로그인된 세션(쿠키)이 있으면 사내 서버 없이도 n8n 사용 가능합니다.
Q: n8n 버전 업그레이드 시 문제가 생길 수 있나요?
A: 가능합니다. n8n DB 스키마가 변경되면 쿼리 수정이 필요할 수 있습니다. roleSlug vs role 같은 컬럼명 변경에 주의하세요.
Q: 테스트는 어떻게 하나요?
A: 더미 인증 모드를 지원합니다. DUMMY_AUTH=true 환경변수를 설정하면 사내 API 없이 테스트 계정으로 로그인 가능합니다.