Stage 5: Professional Skills

Web Security · Lesson 04

HTTPS and Environment Variables

TLS, security headers and managing secrets with .env.

Web Security3 min readFree lesson

Secrets in source code are the most common cause of leaked credentials. HTTPS is the only thing that keeps those credentials and your users' data private in transit. Both are configuration problems, and both are solved once for the whole app.

Learning objectives

  • Explain what TLS protects and what it does not.
  • Redirect HTTP to HTTPS and enable HSTS.
  • Set security headers with sensible defaults.
  • Manage secrets with .env, validation and rotation.

1. What HTTPS gives you

TLS encrypts the connection between browser and server, verifies the server's identity through a certificate, and prevents tampering in transit. Without it:

  • Passwords and session cookies travel in plain text.
  • A public Wi-Fi network can inject scripts into your pages.
  • Browsers mark the site "Not secure" and some APIs refuse to work.

HTTPS does not protect data at rest, authorise users, or stop XSS. It protects the wire.

Hosting: Cloudflare, Vercel and most platforms provision and renew certificates automatically. Force HTTPS at the edge and at the app.

2. Redirect and HSTS

app.use((req, res, next) => {
  if (process.env.NODE_ENV === "production" && req.protocol !== "https") {
    return res.redirect(301, `https://${req.headers.host}${req.originalUrl}`);
  }
  next();
});

If TLS terminates at a proxy, trust the proxy so req.protocol is accurate:

app.set("trust proxy", 1);

HSTS tells the browser to use HTTPS for this domain for a period — even if a link says http://:

Strict-Transport-Security: max-age=31536000; includeSubDomains

Only enable HSTS once HTTPS works on every subdomain; it is difficult to undo. Add preload only when you are certain.

3. Security headers

app.use((req, res, next) => {
  res.setHeader("X-Content-Type-Options", "nosniff");
  res.setHeader("X-Frame-Options", "DENY");
  res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
  res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
  res.setHeader(
    "Content-Security-Policy",
    "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; frame-ancestors 'none'",
  );
  next();
});

helmet packages these defaults if you prefer a dependency. Check each header against your actual needs — a CSP that breaks your own scripts gets removed by the next developer unless it is tuned.

4. Secrets

# .env — never committed
PORT=3000
NODE_ENV=production
DATABASE_URL=postgres://app:password@db:5432/app
SESSION_SECRET=64-char-random-hex
JWT_SECRET=another-64-char-random-hex
require("dotenv").config();

const required = ["DATABASE_URL", "SESSION_SECRET", "JWT_SECRET"];
const missing = required.filter((key) => !process.env[key]);

if (missing.length > 0) {
  console.error(`Missing environment variables: ${missing.join(", ")}`);
  process.exit(1);
}

Fail fast at startup. A server that boots with an empty secret is worse than one that refuses to start.

Generate secrets with a CSPRNG:

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

5. Handling leaked secrets

  1. Revoke the key at the provider immediately.
  2. Generate a new one and deploy it through the environment.
  3. Remove it from the working tree and from Git history (BFG or git filter-repo).
  4. Assume it was used. Check logs for unusual access.
  5. Add a secret scanner (GitHub secret scanning, gitleaks) to CI.

Deleting the file in a new commit does not remove it from history.

6. Production configuration

  • Set NODE_ENV=production (Express disables stack traces).
  • Enable app.set("trust proxy", 1) behind a load balancer.
  • Use a session store (Redis) instead of the default memory store.
  • Rotate secrets on a schedule and after any staff change.
  • Never log request bodies, tokens or cookies.

Common mistakes

  • Committing .env and only deleting it later.
  • secure: false cookies in production "because login did not work on HTTP".
  • HSTS with preload on a domain that still has HTTP-only subdomains.
  • Logging full request headers, including Authorization.
  • One shared secret for sessions, JWT and API keys.

Practice

Harden the Express app in the assignment: validate env vars, set headers, force HTTPS in production and produce a clean .env.example.

Example files

Complete, runnable files used in this lesson. Copy them into your project and experiment.

.env.exampleConfig
# ============================================
# 환경변수 템플릿 (.env.example)
# ============================================
# 사용법:
#   cp .env.example .env
#   .env 파일을 열고 실제 값으로 교체
#
# 주의사항:
#   - .env 파일은 절대 Git에 커밋하지 마세요!
#   - .gitignore에 .env를 추가했는지 확인하세요.
#   - 이 파일(.env.example)은 실제 값이 없으므로 Git에 포함해도 됩니다.
# ============================================

# ── 서버 설정 ──
NODE_ENV=development        # development | production
PORT=3000

# ── 데이터베이스 ──
# MongoDB Atlas 연결 문자열
# 예: mongodb+srv://username:[email protected]/myDatabase
DATABASE_URL=your_mongodb_connection_string_here

# SQLite는 환경변수 불필요 (파일 경로만)
SQLITE_PATH=./data/app.db

# ── 인증 ──
# 최소 32자 이상의 랜덤 문자열 권장
# 생성: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
JWT_SECRET=replace_with_long_random_string_at_least_32_chars
JWT_EXPIRES_IN=7d

SESSION_SECRET=replace_with_another_long_random_string

# ── 외부 API 키 ──
# 각 서비스의 대시보드에서 발급
OPENAI_API_KEY=sk-your-api-key-here
STRIPE_SECRET_KEY=sk_test_your_stripe_key
KAKAO_API_KEY=your_kakao_api_key
NAVER_CLIENT_ID=your_naver_client_id
NAVER_CLIENT_SECRET=your_naver_client_secret

# ── 이메일 서비스 ──
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
[email protected]
SMTP_PASS=your_app_password

# ── AWS (사용하는 경우) ──
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_REGION=ap-northeast-2
AWS_S3_BUCKET=your-bucket-name
security-headers.jsJavaScript
// ============================================
// 강의04 예제: Express 보안 헤더 미들웨어
// ============================================
// 실행:
//   npm install express dotenv helmet
//   cp .env.example .env
//   node security-headers.js
// ============================================

require('dotenv').config(); // .env 파일 로드 (최상단에 위치해야 함)

const express = require('express');
const app     = express();

const PORT = process.env.PORT || 3000;

// ─────────────────────────────────────────
// 1. 환경변수 검증
//    필수 환경변수가 없으면 서버 시작 거부
// ─────────────────────────────────────────
const REQUIRED_ENV = ['JWT_SECRET', 'SESSION_SECRET'];

const missing = REQUIRED_ENV.filter(key => !process.env[key]);
if (missing.length > 0) {
    console.error(`[필수 환경변수 누락] ${missing.join(', ')}`);
    console.error('.env 파일을 확인하세요. (.env.example 참조)');
    process.exit(1); // 시크릿 없으면 서버 시작 거부
}

// ─────────────────────────────────────────
// 2. 보안 헤더 미들웨어 (직접 구현 버전)
//    실제 프로젝트에서는 helmet 라이브러리 사용 권장
// ─────────────────────────────────────────
function securityHeaders(req, res, next) {
    // HSTS (HTTP Strict Transport Security)
    // 브라우저에게 이 사이트는 항상 HTTPS로 접근하라고 지시
    // 개발 환경에서는 주석 처리 (localhost는 HTTPS 불필요)
    // res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');

    // X-Frame-Options: 클릭재킹(Clickjacking) 방지
    // SAMEORIGIN: 같은 도메인의 iframe에서만 허용
    // DENY: 모든 iframe 금지
    res.setHeader('X-Frame-Options', 'SAMEORIGIN');

    // X-Content-Type-Options: MIME 타입 스니핑 방지
    // 브라우저가 Content-Type을 무시하고 파일 내용으로 타입을 추측하는 것 방지
    res.setHeader('X-Content-Type-Options', 'nosniff');

    // Referrer-Policy: 링크 클릭 시 이전 URL 노출 제한
    // strict-origin-when-cross-origin: 같은 도메인은 전체 URL, 다른 도메인은 출처만
    res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');

    // X-XSS-Protection: 오래된 브라우저의 내장 XSS 필터 활성화
    // 최신 브라우저에서는 CSP가 대체
    res.setHeader('X-XSS-Protection', '1; mode=block');

    // Permissions-Policy: 브라우저 기능 접근 제어
    // geolocation, camera, microphone 등의 권한 제한
    res.setHeader('Permissions-Policy', 'geolocation=(), camera=(), microphone=()');

    // Content-Security-Policy: 리소스 출처 제한 (XSS 방어 핵심)
    // 개발 환경용 (프로덕션에서는 더 엄격하게 설정)
    res.setHeader('Content-Security-Policy', [
        "default-src 'self'",           // 기본: 같은 도메인만 허용
        "script-src 'self'",            // 스크립트: 같은 도메인만
        "style-src 'self' 'unsafe-inline'", // CSS: 인라인 허용 (가능하면 제거)
        "img-src 'self' data: https:",  // 이미지: 같은 도메인 + data URL + HTTPS
        "font-src 'self'",              // 폰트: 같은 도메인만
        "connect-src 'self'",           // AJAX/WebSocket: 같은 도메인만
        "frame-ancestors 'self'",       // iframe으로 포함 허용: 같은 도메인만
    ].join('; '));

    // Server 헤더 제거: 서버 소프트웨어 정보 노출 방지
    res.removeHeader('X-Powered-By'); // Express 기본 헤더 제거

    next();
}

// 모든 요청에 보안 헤더 적용
app.use(securityHeaders);

// ─────────────────────────────────────────
// 3. 라우트
// ─────────────────────────────────────────
app.get('/', (req, res) => {
    res.json({
        message: '보안 헤더가 적용된 서버입니다.',
        hint:    'Chrome DevTools → Network → 응답 헤더를 확인하세요.',
        headers: {
            'X-Frame-Options':       res.getHeader('X-Frame-Options'),
            'X-Content-Type-Options':res.getHeader('X-Content-Type-Options'),
            'Referrer-Policy':       res.getHeader('Referrer-Policy'),
        },
    });
});

// 환경변수 테스트 (시크릿은 절대 응답에 포함하지 않음)
app.get('/config', (req, res) => {
    res.json({
        env:     process.env.NODE_ENV || 'development',
        port:    PORT,
        // JWT_SECRET: process.env.JWT_SECRET  ← 절대 이렇게 노출하면 안 됨!
        hasJwtSecret:     !!process.env.JWT_SECRET,     // 존재 여부만 반환
        hasSessionSecret: !!process.env.SESSION_SECRET, // 값은 절대 반환 금지
    });
});

// ─────────────────────────────────────────
// 4. 서버 시작
// ─────────────────────────────────────────
app.listen(PORT, () => {
    console.log(`보안 헤더 예제 서버: http://localhost:${PORT}`);
    console.log('Chrome DevTools > Network > 응답 헤더 확인');
});

Assignment

Assignment 04 — Secure Express configuration

Create secure-app.js and .env.example for a production-ready Express app.

Requirements

  1. Load .env with dotenv.
  2. Validate required variables at startup: PORT, NODE_ENV, DATABASE_URL, SESSION_SECRET, JWT_SECRET. Exit with a clear message when any are missing.
  3. Reject a SESSION_SECRET or JWT_SECRET shorter than 32 characters.
  4. Enable app.set("trust proxy", 1).
  5. Redirect HTTP to HTTPS when NODE_ENV=production.
  6. Set these headers on every response:
    • Strict-Transport-Security: max-age=31536000; includeSubDomains
    • X-Content-Type-Options: nosniff
    • X-Frame-Options: DENY
    • Referrer-Policy: strict-origin-when-cross-origin
    • Permissions-Policy: camera=(), microphone=(), geolocation=()
    • a Content-Security-Policy with default-src 'self'
  7. Add GET /health returning { status: "ok", env } without exposing secrets.
  8. Log a startup line with the port and environment — never the secrets themselves.

.env.example

Include every required key with placeholder values and comments explaining how to generate the random secrets:

# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
SESSION_SECRET=replace_with_64_char_hex
JWT_SECRET=replace_with_64_char_hex

Test

node -e "delete process.env.SESSION_SECRET; require('./secure-app.js')"
# expect a clear startup failure

curl -I http://localhost:3000/health
# expect the security headers

Done when

  • Starting without a required variable exits with code 1 and names the missing key.
  • .env is in .gitignore and not tracked.
  • /health returns JSON without any secret values.
  • The response headers include HSTS and CSP.
Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

.env.exampleConfig
# ============================================
# 과제04 정답: 환경변수 템플릿
# ============================================
# 사용법:
#   cp .env.example .env
#   .env 파일에서 실제 값으로 교체
#
# 보안 원칙:
#   - .env 파일은 절대 Git에 커밋하지 않는다
#   - .gitignore에 .env가 있는지 항상 확인
#   - 이 파일(.env.example)은 실제 값이 없으므로 Git에 포함해도 됨
#   - 팀원에게 .env를 이메일로 공유하지 말 것 (안전한 채널 사용)
# ============================================

# ── 서버 기본 설정 ──
NODE_ENV=development
PORT=3000

# ── 데이터베이스 연결 문자열 ──
# MongoDB Atlas: 대시보드 > Connect > Connect your application
# 예시 형식: mongodb+srv://username:[email protected]/dbname
MONGODB_URI=mongodb+srv://your_username:[email protected]/your_db

# ── 인증 시크릿 ──
# 생성 명령어:
#   node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# 최소 32자 이상의 랜덤 문자열 사용

# JWT 토큰 서명에 사용되는 시크릿 키
JWT_SECRET=replace_with_64_char_random_hex_string_here

# 세션 쿠키 서명에 사용되는 시크릿
SESSION_SECRET=replace_with_another_64_char_random_hex_string

# ── 외부 API 키 ──
# 각 서비스의 개발자 대시보드에서 발급

# 외부 데이터 API 키 (예시)
EXTERNAL_API_KEY=your_api_key_here

# ── 이메일 (선택) ──
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
[email protected]
SMTP_PASS=your_gmail_app_password
secure-app.jsJavaScript
// ============================================
// 과제04 정답: 환경변수로 시크릿 분리
// ============================================
// 실행 전:
//   1. npm install express dotenv
//   2. cp .env.example .env
//   3. .env 파일에 실제 값 입력
//   4. node secure-app.js
// ============================================

// dotenv: .env 파일의 내용을 process.env에 로드
// 반드시 다른 require보다 먼저 호출해야 함
require('dotenv').config();

const express = require('express');
const app = express();

// ─────────────────────────────────────────
// 1. 필수 환경변수 검증 함수
//    시크릿이 설정되지 않으면 서버 시작을 거부
//    빈 문자열도 허용하지 않음
// ─────────────────────────────────────────
function validateEnv() {
    const required = [
        'MONGODB_URI',    // DB 연결 문자열
        'JWT_SECRET',     // JWT 서명 키
        'SESSION_SECRET', // 세션 암호화 키
    ];

    const missing = required.filter(key => !process.env[key] || process.env[key].trim() === '');

    if (missing.length > 0) {
        console.error('');
        console.error('══════════════════════════════════════════');
        console.error('  [오류] 필수 환경변수가 설정되지 않았습니다');
        console.error('══════════════════════════════════════════');
        console.error('  누락된 변수:', missing.join(', '));
        console.error('  .env.example을 복사하여 .env 파일을 만들고');
        console.error('  실제 값으로 교체하세요.');
        console.error('══════════════════════════════════════════');
        console.error('');
        process.exit(1); // 환경변수 없으면 서버 시작 거부
    }

    // JWT 시크릿 길이 검증 (최소 32자)
    if (process.env.JWT_SECRET.length < 32) {
        console.error('[경고] JWT_SECRET이 너무 짧습니다. 최소 32자 이상을 권장합니다.');
        process.exit(1);
    }

    console.log('환경변수 검증 통과');
}

// 서버 시작 전 검증 실행
validateEnv();

// ─────────────────────────────────────────
// 2. 환경변수에서 설정값 읽기
//    process.env 직접 사용 대신 별도 변수에 저장
//    → 타입 변환, 기본값 처리를 한 곳에서 관리
// ─────────────────────────────────────────
const config = {
    port:           parseInt(process.env.PORT) || 3000,   // 숫자로 변환, 기본값 3000
    nodeEnv:        process.env.NODE_ENV || 'development',
    mongodbUri:     process.env.MONGODB_URI,              // DB 연결 문자열
    jwtSecret:      process.env.JWT_SECRET,               // JWT 서명 키
    sessionSecret:  process.env.SESSION_SECRET,           // 세션 키
    apiKey:         process.env.EXTERNAL_API_KEY,         // 외부 API 키 (선택)
};

// 설정 요약 출력 (값은 숨기고 존재 여부만)
console.log('서버 설정:');
console.log('  환경:', config.nodeEnv);
console.log('  포트:', config.port);
console.log('  DB 연결:', config.mongodbUri ? '설정됨' : '미설정');
console.log('  JWT 시크릿:', config.jwtSecret ? '설정됨' : '미설정');

// ─────────────────────────────────────────
// 3. Express 앱 설정
// ─────────────────────────────────────────
app.use(express.json());

// ─────────────────────────────────────────
// 4. 라우트
// ─────────────────────────────────────────

// GET / — 서버 상태 확인
app.get('/', (req, res) => {
    res.json({
        status:  'OK',
        env:     config.nodeEnv,
        message: '환경변수가 올바르게 설정되어 있습니다.',

        // ✓ 환경변수 값은 절대 응답에 포함하지 않음!
        // jwtSecret: config.jwtSecret  ← 절대 금지
        // mongodbUri: config.mongodbUri ← 절대 금지

        // 존재 여부만 반환 (값은 숨김)
        configured: {
            database:      !!config.mongodbUri,
            jwtSecret:     !!config.jwtSecret,
            sessionSecret: !!config.sessionSecret,
            externalApi:   !!config.apiKey,
        },
    });
});

// GET /data — 외부 API 호출 예시 (환경변수에서 키 사용)
app.get('/data', async (req, res) => {
    if (!config.apiKey) {
        return res.status(503).json({
            error: '외부 API 키가 설정되지 않았습니다. .env 파일을 확인하세요.',
        });
    }

    try {
        // 환경변수에서 API 키를 가져와 헤더에 포함
        // 코드에 키를 직접 쓰지 않음
        const response = await fetch('https://api.example.com/data', {
            headers: { 'Authorization': `Bearer ${config.apiKey}` }
        });

        const data = await response.json();
        res.json(data);
    } catch (err) {
        console.error('[API 에러]', err.message); // 로그에는 상세 기록
        res.status(500).json({ error: '외부 서비스 연결에 실패했습니다.' });
    }
});

// ─────────────────────────────────────────
// 5. 서버 시작
// ─────────────────────────────────────────
app.listen(config.port, () => {
    console.log(`서버 실행: http://localhost:${config.port}`);
});

Learn this lesson with a mentor

Work through the Build Your Homepage series on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now