Stage 5: Professional Skills

Web Security · Lesson 02

CSRF and Authentication

SameSite cookies, CSRF tokens and JWT authentication basics.

Web Security3 min readFree lesson

Cross-site request forgery tricks a signed-in user's browser into sending a request they did not intend. The browser attaches cookies automatically — that is the whole attack.

Learning objectives

  • Explain how a CSRF attack works.
  • Defend with SameSite cookies and synchroniser tokens.
  • Compare session cookies with JWT authentication.
  • Store credentials and secrets safely.

1. The attack

The victim is signed in to bank.example (session cookie set). They visit evil.example, which contains:

<form action="https://bank.example/transfer" method="POST">
  <input name="to" value="attacker">
  <input name="amount" value="50000">
</form>
<script>document.forms[0].submit()</script>

The browser posts to the bank with the bank's cookie attached. The bank sees a valid session and transfers the money. The attacker never read the cookie; they only made the browser use it.

CSRF only affects requests that carry ambient credentials (cookies, HTTP auth). A pure Authorization: Bearer header from JavaScript is not sent automatically, so it is not vulnerable in the same way.

2. SameSite cookies

res.cookie("session", token, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",   // or "strict"
});
  • Strict — cookie is never sent on cross-site requests. Safest, but following a link from email shows the signed-out page.
  • Lax — sent on top-level GET navigations, not on cross-site POSTs. A good default.
  • None — sent everywhere; requires Secure. Needed only for genuine cross-site embeds.

SameSite alone blocks most CSRF, but older browsers and unusual flows still need tokens.

3. Synchroniser token

  1. On session creation, generate a random token and store it in the session.
  2. Embed it in every state-changing form (or send it as a header).
  3. On POST/PUT/DELETE, compare the submitted token with the session token using a timing-safe comparison.
const crypto = require("node:crypto");

function ensureCsrfToken(req, res, next) {
  if (!req.session.csrfToken) {
    req.session.csrfToken = crypto.randomBytes(32).toString("hex");
  }
  res.locals.csrfToken = req.session.csrfToken;
  next();
}

function verifyCsrf(req, res, next) {
  if (!["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) return next();

  const sent = req.body._csrf || req.headers["x-csrf-token"];
  const stored = req.session.csrfToken;

  if (!sent || !stored || sent.length !== stored.length ||
      !crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(stored))) {
    return res.status(403).send("Invalid CSRF token");
  }

  next();
}

An attacker on another origin cannot read the token from your page, so they cannot include it in the forged form.

4. Sessions vs JWT

Server session JWT
State stored server-side self-contained token
Revocation delete the session hard until expiry
Storage HttpOnly cookie HttpOnly cookie (not localStorage)
Scaling shared store (Redis) stateless
Best for web apps APIs, service-to-service

If you use JWT in a browser, keep it in an HttpOnly, Secure, SameSite cookie — not in localStorage, where any XSS can read it. Keep access tokens short-lived and rotate refresh tokens.

5. Passwords

const bcrypt = require("bcrypt");

const hash = await bcrypt.hash(password, 12);
const ok = await bcrypt.compare(candidate, hash);

Never store plain text or a fast hash (MD5/SHA-1). bcrypt, scrypt or Argon2 are designed to be slow. Enforce a reasonable minimum length and check breached-password lists rather than complex composition rules.

6. Other essentials

  • app.use(express.urlencoded({ extended: false })) — do not accept arbitrary nested objects unless needed.
  • Validate Origin / Referer on sensitive endpoints as an extra check.
  • Do not put state-changing operations behind GET.
  • Log out by destroying the session server-side, not just clearing the cookie.
  • Use HTTPS everywhere; without it, tokens and cookies are readable in transit.

Common mistakes

  • CSRF tokens stored in a cookie that the attacker can also cause to be sent.
  • SameSite=None without Secure.
  • JWT in localStorage plus any XSS equals a stolen session.
  • Checking the token only on some routes.
  • Using == instead of a timing-safe comparison for secrets.

Practice

Implement the session + CSRF flow in the assignment and verify that a forged POST without the token returns 403.

Example files

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

server.jsJavaScript
// ============================================
// 강의02 예제: Express CSRF 토큰 미들웨어 구현
// ============================================
// 실행: npm install express express-session && node server.js
// ============================================

const express = require('express');
const session = require('express-session');
const crypto  = require('crypto');

const app = express();

// JSON + URL 인코딩 바디 파싱
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// ─────────────────────────────────────────
// 1. 세션 설정
//    secret: 세션 쿠키 서명에 사용 (반드시 환경변수로 관리)
// ─────────────────────────────────────────
app.use(session({
    secret:            process.env.SESSION_SECRET || 'dev-secret-change-in-prod',
    resave:            false,
    saveUninitialized: false,
    cookie: {
        httpOnly: true,    // XSS로 쿠키 탈취 방지
        secure:   false,   // HTTPS 환경에서는 true로 설정
        sameSite: 'Lax',   // CSRF 기본 방어
        maxAge:   30 * 60 * 1000, // 30분
    },
}));

// ─────────────────────────────────────────
// 2. CSRF 토큰 생성 미들웨어
//    모든 요청에서 세션에 CSRF 토큰을 생성/유지
// ─────────────────────────────────────────
function generateCsrfToken(req, res, next) {
    // 세션에 토큰이 없으면 새로 생성
    if (!req.session.csrfToken) {
        // crypto.randomBytes: 암호학적으로 안전한 랜덤값
        req.session.csrfToken = crypto.randomBytes(32).toString('hex');
    }
    next();
}
app.use(generateCsrfToken);

// ─────────────────────────────────────────
// 3. CSRF 토큰 검증 미들웨어
//    상태 변경 요청(POST/PUT/DELETE)에만 적용
// ─────────────────────────────────────────
function verifyCsrfToken(req, res, next) {
    // GET, HEAD, OPTIONS는 상태를 변경하지 않으므로 건너뜀
    if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
        return next();
    }

    // 요청에서 CSRF 토큰 추출 (폼 바디 또는 헤더)
    const submittedToken = req.body._csrf || req.headers['x-csrf-token'];
    const storedToken    = req.session.csrfToken;

    // 토큰 비교 (timingSafeEqual: 타이밍 공격 방지)
    const isValid = submittedToken && storedToken &&
        crypto.timingSafeEqual(
            Buffer.from(submittedToken, 'hex'),
            Buffer.from(storedToken,    'hex')
        );

    if (!isValid) {
        return res.status(403).json({
            error: 'CSRF 토큰이 유효하지 않습니다. 페이지를 새로고침 후 다시 시도하세요.'
        });
    }

    next();
}

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

// GET / — 로그인 폼 페이지
app.get('/', (req, res) => {
    const csrfToken = req.session.csrfToken;

    // HTML에 CSRF 토큰을 hidden input으로 포함
    // 공격자의 사이트에서는 이 토큰을 알 수 없음
    res.send(`
        <!DOCTYPE html>
        <html lang="ko">
        <head>
            <meta charset="UTF-8">
            <title>CSRF 방어 예제</title>
            <style>
                body { font-family: sans-serif; max-width: 500px; margin: 40px auto; padding: 0 20px; }
                input { display: block; width: 100%; margin: 8px 0; padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
                button { padding: 10px 20px; background: #0066cc; color: white; border: none; border-radius: 4px; cursor: pointer; }
                .info { background: #e8f4fd; padding: 12px; border-radius: 6px; margin-bottom: 20px; font-size: 0.9rem; }
            </style>
        </head>
        <body>
            <h1>CSRF 방어 예제</h1>
            <div class="info">
                CSRF 토큰이 hidden input으로 포함됩니다.<br>
                개발자 도구에서 확인: <code>input[name="_csrf"]</code>
            </div>

            <form method="POST" action="/transfer">
                <!-- CSRF 토큰: 숨겨진 필드로 포함 -->
                <input type="hidden" name="_csrf" value="${csrfToken}">

                <label>수신자 계좌번호</label>
                <input type="text" name="to" placeholder="123-456-7890" required>

                <label>금액 (원)</label>
                <input type="number" name="amount" placeholder="10000" required>

                <button type="submit">송금하기</button>
            </form>

            <hr style="margin: 30px 0">

            <h2>CSRF 공격 시뮬레이션</h2>
            <p>아래 버튼은 CSRF 토큰 없이 요청합니다 (403 에러가 발생해야 함):</p>
            <button onclick="simulateAttack()">악성 요청 시뮬레이션</button>
            <div id="attack-result" style="margin-top: 12px; color: red;"></div>

            <script>
                async function simulateAttack() {
                    // CSRF 토큰 없이 POST 요청 → 서버에서 403 반환
                    const res = await fetch('/transfer', {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({ to: '악성계좌', amount: 999999 })
                        // _csrf 토큰 없음!
                    });
                    const data = await res.json();
                    document.getElementById('attack-result').textContent =
                        '서버 응답 (' + res.status + '): ' + JSON.stringify(data);
                }
            </script>
        </body>
        </html>
    `);
});

// POST /transfer — 송금 처리 (CSRF 검증 적용)
app.post('/transfer', verifyCsrfToken, (req, res) => {
    const { to, amount } = req.body;

    if (!to || !amount) {
        return res.status(400).json({ error: '수신자와 금액을 입력해주세요.' });
    }

    // 실제로는 DB에서 잔액 확인, 트랜잭션 처리 등
    console.log(`송금 처리: ${to}에게 ${amount}원`);

    // 폼 제출인 경우 리다이렉트, API 호출인 경우 JSON 반환
    if (req.headers['content-type']?.includes('application/x-www-form-urlencoded')) {
        res.send(`
            <h2>송금 완료</h2>
            <p>${to}에게 ${Number(amount).toLocaleString()}원을 송금했습니다.</p>
            <a href="/">돌아가기</a>
        `);
    } else {
        res.json({ success: true, message: '송금 완료' });
    }
});

// ─────────────────────────────────────────
// 5. 서버 시작
// ─────────────────────────────────────────
app.listen(3000, () => {
    console.log('CSRF 방어 예제 서버: http://localhost:3000');
});

Assignment

Assignment 02 — CSRF-protected Express app

Create csrf-protected.js with sessions, a login form and a protected transfer endpoint.

Setup

npm install express express-session

SESSION_SECRET comes from .env; never hardcode it.

Requirements

  1. express-session with httpOnly: true, sameSite: "lax", secure in production, and resave: false, saveUninitialized: false.
  2. Generate a 32-byte CSRF token per session (crypto.randomBytes(32).toString("hex")).
  3. GET / renders a form containing a hidden _csrf input and the current balance.
  4. POST /login sets req.session.userId.
  5. POST /transfer requires a valid session and a valid CSRF token, then deducts the amount.
  6. Invalid or missing token returns 403 and logs the attempt.
  7. POST /logout destroys the session.
  8. Tokens are compared with crypto.timingSafeEqual.

Hints

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    sameSite: "lax",
    secure: process.env.NODE_ENV === "production",
  },
}));
function verifyCsrf(req, res, next) {
  if (!["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) return next();

  const sent = req.body._csrf || req.headers["x-csrf-token"];
  const stored = req.session.csrfToken;
  if (!sent || !stored || sent.length !== stored.length) {
    return res.status(403).send("Invalid CSRF token");
  }
  if (!crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(stored))) {
    return res.status(403).send("Invalid CSRF token");
  }
  next();
}

Test

curl -i -X POST http://localhost:3000/transfer -d "amount=100"
# expect 403

curl -i -c cookies.txt http://localhost:3000/
# copy the _csrf value from the HTML, then:
curl -i -b cookies.txt -X POST http://localhost:3000/transfer \
  -d "amount=100&_csrf=<token>"
# expect 200

Done when

  • A POST without the token is rejected with 403.
  • A POST with another session's token is rejected.
  • Logging out invalidates the session and the old token.
  • The cookie shows HttpOnly and SameSite=Lax in the browser devtools.
Show the reference solution

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

csrf-protected.jsJavaScript
// ============================================
// 과제02 정답: CSRF 방어된 로그인 폼
// ============================================
// 실행: npm install express express-session && node csrf-protected.js
// ============================================

const express = require('express');
const session = require('express-session');
const crypto  = require('crypto');

const app = express();

// 요청 바디 파싱 (폼 데이터 + JSON)
app.use(express.urlencoded({ extended: true }));
app.use(express.json());

// ─────────────────────────────────────────
// 1. 세션 설정
//    실제 서비스: SESSION_SECRET을 .env에서 읽어야 함
// ─────────────────────────────────────────
app.use(session({
    secret:            process.env.SESSION_SECRET || 'change-this-in-production',
    resave:            false,     // 변경 없는 세션은 저장 안 함
    saveUninitialized: false,     // 빈 세션은 저장 안 함
    cookie: {
        httpOnly: true,            // JavaScript에서 쿠키 접근 불가 → XSS 방어
        secure:   false,           // 개발 환경: false / 프로덕션 HTTPS: true
        sameSite: 'Lax',           // 크로스 사이트 POST 요청 쿠키 차단 → CSRF 기본 방어
        maxAge:   60 * 60 * 1000,  // 세션 유효시간: 1시간
    },
}));

// ─────────────────────────────────────────
// 2. 테스트용 사용자 데이터 (실제로는 DB에서 관리)
// ─────────────────────────────────────────
const USERS = [
    { id: 1, username: 'admin', password: 'admin123' },
    { id: 2, username: 'user1', password: 'pass123'  },
];

// ─────────────────────────────────────────
// 3. CSRF 토큰 생성 미들웨어
//    모든 요청에서 실행: 세션에 토큰이 없으면 새로 발급
// ─────────────────────────────────────────
function ensureCsrfToken(req, res, next) {
    if (!req.session.csrfToken) {
        // 암호학적으로 안전한 랜덤 토큰 생성 (64자리 16진수)
        req.session.csrfToken = crypto.randomBytes(32).toString('hex');
    }
    next();
}
app.use(ensureCsrfToken);

// ─────────────────────────────────────────
// 4. CSRF 토큰 검증 미들웨어
//    상태 변경 요청에만 적용 (POST/PUT/DELETE)
// ─────────────────────────────────────────
function verifyCsrf(req, res, next) {
    if (!['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
        return next(); // GET 등은 상태 변경이 없으므로 건너뜀
    }

    // 요청에서 CSRF 토큰 추출 (폼 바디 또는 커스텀 헤더)
    const sentToken   = req.body._csrf || req.headers['x-csrf-token'];
    const storedToken = req.session.csrfToken;

    // 두 토큰이 모두 있고, 같은지 비교
    // timingSafeEqual: 타이밍 공격(Timing Attack) 방지
    const isValid = sentToken && storedToken &&
        crypto.timingSafeEqual(
            Buffer.from(sentToken,   'hex'),
            Buffer.from(storedToken, 'hex')
        );

    if (!isValid) {
        // 403 Forbidden: CSRF 토큰 검증 실패
        console.warn(`[보안] CSRF 토큰 검증 실패 - IP: ${req.ip}, URL: ${req.path}`);
        return res.status(403).send(`
            <h2>403 Forbidden</h2>
            <p>CSRF 토큰이 유효하지 않습니다.</p>
            <p>페이지를 새로고침 후 다시 시도하세요.</p>
            <a href="/">처음으로</a>
        `);
    }

    next();
}

// ─────────────────────────────────────────
// 5. 인증 확인 미들웨어
//    보호된 라우트에만 적용
// ─────────────────────────────────────────
function requireAuth(req, res, next) {
    if (!req.session.userId) {
        // 미로그인 상태 → 로그인 페이지로 리다이렉트
        return res.redirect('/login');
    }
    next();
}

// ─────────────────────────────────────────
// 6. 라우트
// ─────────────────────────────────────────

// GET / → 로그인 페이지로 리다이렉트
app.get('/', (req, res) => {
    if (req.session.userId) {
        res.redirect('/dashboard');
    } else {
        res.redirect('/login');
    }
});

// GET /login — 로그인 폼
app.get('/login', (req, res) => {
    if (req.session.userId) {
        return res.redirect('/dashboard'); // 이미 로그인 상태면 대시보드로
    }

    const errorMsg  = req.query.error  || '';
    const csrfToken = req.session.csrfToken; // 세션에서 토큰 꺼내기

    res.send(`
        <!DOCTYPE html>
        <html lang="ko">
        <head>
            <meta charset="UTF-8">
            <title>로그인</title>
            <style>
                body { font-family: sans-serif; max-width: 400px; margin: 100px auto; padding: 0 20px; }
                .form-group { margin-bottom: 16px; }
                label { display: block; font-weight: bold; margin-bottom: 4px; }
                input { width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
                button { width: 100%; padding: 12px; background: #0066cc; color: white; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; }
                .error { color: #cc0000; background: #fff0f0; padding: 10px; border-radius: 4px; margin-bottom: 16px; }
                .info  { background: #f0f4ff; padding: 10px; border-radius: 4px; margin-bottom: 16px; font-size: 0.85rem; }
            </style>
        </head>
        <body>
            <h1>로그인</h1>

            <div class="info">
                테스트 계정: admin / admin123 또는 user1 / pass123
            </div>

            ${errorMsg ? `<div class="error">${errorMsg}</div>` : ''}

            <form method="POST" action="/login">
                <!-- CSRF 토큰: hidden input으로 포함
                     공격자의 사이트에서는 이 값을 알 수 없음 -->
                <input type="hidden" name="_csrf" value="${csrfToken}">

                <div class="form-group">
                    <label for="username">아이디</label>
                    <input type="text" id="username" name="username" required autocomplete="username">
                </div>

                <div class="form-group">
                    <label for="password">비밀번호</label>
                    <input type="password" id="password" name="password" required autocomplete="current-password">
                </div>

                <button type="submit">로그인</button>
            </form>
        </body>
        </html>
    `);
});

// POST /login — 로그인 처리 (CSRF 검증 포함)
app.post('/login', verifyCsrf, (req, res) => {
    const { username, password } = req.body;

    // 사용자 조회 (실제로는 DB 조회 + 비밀번호 해시 비교)
    const user = USERS.find(u => u.username === username && u.password === password);

    if (!user) {
        // 로그인 실패: 구체적인 오류(아이디 없음 / 비밀번호 틀림)를 알려주지 않음
        // (사용자 계정 존재 여부를 노출하지 않기 위해)
        return res.redirect('/login?error=아이디 또는 비밀번호가 올바르지 않습니다.');
    }

    // 세션 고정 공격 방지: 로그인 성공 시 세션 재생성
    req.session.regenerate((err) => {
        if (err) return res.status(500).send('서버 오류');

        // 새 세션에 사용자 정보 저장
        req.session.userId   = user.id;
        req.session.username = user.username;

        // 새 세션에도 CSRF 토큰 발급
        req.session.csrfToken = crypto.randomBytes(32).toString('hex');

        res.redirect('/dashboard');
    });
});

// GET /dashboard — 보호된 페이지 (로그인 필요)
app.get('/dashboard', requireAuth, (req, res) => {
    const csrfToken = req.session.csrfToken;

    res.send(`
        <!DOCTYPE html>
        <html lang="ko">
        <head>
            <meta charset="UTF-8">
            <title>대시보드</title>
            <style>
                body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
                .user-info { background: #e8f5e9; padding: 16px; border-radius: 8px; margin-bottom: 20px; }
                button { padding: 10px 20px; background: #cc0000; color: white; border: none; border-radius: 4px; cursor: pointer; }
            </style>
        </head>
        <body>
            <h1>대시보드</h1>
            <div class="user-info">
                <strong>${req.session.username}</strong>님 환영합니다!
            </div>

            <!-- 로그아웃 폼: POST 요청 + CSRF 토큰 -->
            <form method="POST" action="/logout">
                <!-- CSRF 토큰으로 로그아웃 요청도 보호 -->
                <input type="hidden" name="_csrf" value="${csrfToken}">
                <button type="submit">로그아웃</button>
            </form>
        </body>
        </html>
    `);
});

// POST /logout — 로그아웃 (CSRF 검증 포함)
app.post('/logout', requireAuth, verifyCsrf, (req, res) => {
    // 세션 파기: 모든 세션 데이터 삭제
    req.session.destroy((err) => {
        if (err) return res.status(500).send('로그아웃 실패');
        res.clearCookie('connect.sid'); // 쿠키 삭제
        res.redirect('/login');
    });
});

// ─────────────────────────────────────────
// 7. 서버 시작
// ─────────────────────────────────────────
app.listen(3000, () => {
    console.log('CSRF 방어 로그인 서버: http://localhost:3000');
    console.log('테스트 계정: admin / admin123');
});

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