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}`);
});

핵심 포인트:

  1. /signin, /signout/login 리다이렉트로 n8n 기본 로그인 페이지 우회
  2. 이미 로그인된 상태에서 /login 접근 시 메인으로 리다이렉트
  3. http-proxy-middleware로 모든 n8n 요청 프록시
  4. 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 없이 테스트 계정으로 로그인 가능합니다.


8. 참고 자료