The demo below simulates two scenarios side by side. On the left, a legitimate user submits a form that carries the secret CSRF token the server issued when the page loaded. On the right, an attacker's forged request arrives without that token — or with a wrong one.
<!-- Two-column layout — legitimate form (left) vs attacker form (right) -->
<div class="panel-row">
<!-- Left panel — legitimate user with correct CSRF token -->
<div class="panel" id="legit-panel">
<h3>Legitimate User</h3>
<p class="desc">This form was served by the real site. It carries the CSRF token the server embedded.</p>
<div class="token-display">
<span class="label">CSRF token:</span>
<code id="legit-token" class="token-val"></code>
</div>
<form id="legit-form" class="form">
<input type="hidden" id="legit-csrf" />
<label>To: <input id="legit-to" type="text" value="Alice" /></label>
<label>Amount ($): <input id="legit-amount" type="number" value="50" min="1" max="500" /></label>
<button type="submit">Send Transfer</button>
</form>
<div id="legit-result" class="result"></div>
</div>
<!-- Right panel — attacker form with a guessed/fake token -->
<div class="panel" id="attacker-panel">
<h3>Attacker’s Forged Form</h3>
<p class="desc">This form lives on evil.example. It cannot read the real page’s token (same-origin policy blocks it).</p>
<div class="token-display">
<span class="label">Fake token (guessed):</span>
<code id="attacker-token-shown" class="token-val danger"></code>
</div>
<form id="attacker-form" class="form">
<label>To: <input id="attacker-to" type="text" value="Eve" /></label>
<label>Amount ($): <input id="attacker-amount" type="number" value="500" min="1" max="500" /></label>
<button type="submit">Forge Transfer</button>
</form>
<div id="attacker-result" class="result"></div>
</div>
</div>
<div class="log-area">
<div class="log-header">
<span>Server log</span>
<button id="reset-btn" type="button" class="ghost">Clear log</button>
</div>
<ul id="log"></ul>
</div>
/* Base layout and typography */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
h3 { margin: 0 0 .4rem; font-size: 1rem; }
.panel-row { display: flex; gap: .8rem; flex-wrap: wrap; }
.panel { flex: 1 1 220px; border: 1px solid #cdd9e3; border-radius: 10px; padding: .8rem; background: #f4f8fb; }
#attacker-panel { border-color: #e63946; background: #fff5f5; }
/* Token display badge */
.token-display { margin: .4rem 0 .6rem; display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
.label { font-size: .78rem; color: #555; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; }
.token-val { font-size: .72rem; background: #e3edf5; border-radius: 4px; padding: 2px 6px; color: #1d3557; word-break: break-all; }
.token-val.danger { background: #fde8ea; color: #c92f3c; }
/* Form inputs and labels */
.form { display: flex; flex-direction: column; gap: .45rem; }
.form label { display: flex; flex-direction: column; font-size: .82rem; color: #333; gap: .15rem; }
.form input[type="text"], .form input[type="number"] {
padding: .3rem .5rem; border: 1px solid #b0bec5; border-radius: 6px; font-size: .9rem;
}
.desc { font-size: .82rem; color: #555; margin: 0 0 .5rem; line-height: 1.4; }
button { margin-top: .4rem; font: 600 13px system-ui; padding: .4rem .8rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
#attacker-panel button[type="submit"] { border-color: #c92f3c; background: #c92f3c; }
button.ghost { background: #fff; color: #1d3557; font-size: .8rem; padding: .25rem .6rem; }
/* Per-panel result messages */
.result { margin-top: .5rem; min-height: 1.3em; font-weight: 600; font-size: .85rem; }
.result.ok { color: #0a7d33; }
.result.bad { color: #c92f3c; }
/* Server log list at the bottom */
.log-area { margin-top: .8rem; border: 1px solid #cdd9e3; border-radius: 8px; background: #fff; }
.log-header { display: flex; align-items: center; justify-content: space-between; padding: .4rem .7rem; border-bottom: 1px solid #e3edf5; font-size: .8rem; font-weight: 700; color: #1d3557; text-transform: uppercase; letter-spacing: .04em; }
#log { list-style: none; margin: 0; padding: .3rem .7rem .5rem; max-height: 150px; overflow-y: auto; }
#log li { font-size: .78rem; font-family: ui-monospace, monospace; padding: .18rem 0; border-bottom: 1px solid #f0f4f7; }
#log li.ok { color: #0a7d33; }
#log li.bad { color: #c92f3c; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Shared secret — known only to the server. In a real app this would
// be an env variable, never exposed to the browser.
const SECRET = 'kipu-demo-secret-2024';
const SESSION_ID = 'user-session-abc123';
// Token = simpleHash(sessionId + ’:’ + SECRET). In production
// use HMAC-SHA256. The key point: the token is bound to the session.
function simpleHash(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = (h * 0x01000193) >>> 0;
}
return h.toString(16).padStart(8, '0');
}
function generateToken(sessionId) {
return simpleHash(sessionId + ':' + SECRET);
}
function verifyToken(tokenFromRequest, sessionId) {
return tokenFromRequest === generateToken(sessionId);
}
// Issue a CSRF token for the legitimate page load
const legitimateToken = generateToken(SESSION_ID);
document.getElementById('legit-token').textContent = legitimateToken;
document.getElementById('legit-csrf').value = legitimateToken;
// The attacker guesses a fake token — they cannot read the real one (same-origin policy)
const attackerFakeToken = 'deadbeef';
document.getElementById('attacker-token-shown').textContent = attackerFakeToken;
function addLog(msg, cls) {
const li = document.createElement('li');
li.textContent = msg;
li.className = cls;
const log = document.getElementById('log');
log.insertBefore(li, log.firstChild);
}
function setResult(id, msg, cls) {
const el = document.getElementById(id);
el.textContent = msg;
el.className = 'result ' + cls;
}
// Legitimate form submit — token matches
document.getElementById('legit-form').addEventListener('submit', function(e) {
e.preventDefault();
const token = document.getElementById('legit-csrf').value;
const to = document.getElementById('legit-to').value;
const amount = document.getElementById('legit-amount').value;
const valid = verifyToken(token, SESSION_ID);
if (valid) {
setResult('legit-result', i18nFmt('Transfer of $%amount% to %to% approved.', { to, amount }), 'ok');
addLog(i18nFmt('[OK] Transfer $%amount% -> %to% | token verified', { to, amount }), 'ok');
} else {
setResult('legit-result', 'Token mismatch — request rejected.', 'bad');
addLog('[REJECT] Token mismatch', 'bad');
}
});
// Attacker form submit — token does NOT match (blocked)
document.getElementById('attacker-form').addEventListener('submit', function(e) {
e.preventDefault();
const token = attackerFakeToken;
const to = document.getElementById('attacker-to').value;
const amount = document.getElementById('attacker-amount').value;
const valid = verifyToken(token, SESSION_ID);
if (valid) {
setResult('attacker-result', i18nFmt('Forged transfer of $%amount% to %to% succeeded! (token was correct — should not happen)', { to, amount }), 'ok');
addLog(i18nFmt('[WARN] Forged request accepted — token %token% matched (unexpected)', { to, amount }), 'ok');
} else {
setResult('attacker-result', 'Blocked! Token invalid — CSRF attack stopped.', 'bad');
addLog(i18nFmt('[BLOCK] Forged request rejected — token ’%token%’ invalid', { token }), 'bad');
}
});
// Clear logs and result messages
document.getElementById('reset-btn').addEventListener('click', function() {
document.getElementById('log').innerHTML = '';
setResult('legit-result', '', '');
setResult('attacker-result', '', '');
});
<!-- Two-column layout — legitimate form (left) vs attacker form (right) -->
<div class="panel-row">
<!-- Left panel — legitimate user with correct CSRF token -->
<div class="panel" id="legit-panel">
<h3>Legitimate User</h3>
<p class="desc">This form was served by the real site. It carries the CSRF token the server embedded.</p>
<div class="token-display">
<span class="label">CSRF token:</span>
<code id="legit-token" class="token-val"></code>
</div>
<form id="legit-form" class="form">
<input type="hidden" id="legit-csrf" />
<label>To: <input id="legit-to" type="text" value="Alice" /></label>
<label>Amount ($): <input id="legit-amount" type="number" value="50" min="1" max="500" /></label>
<button type="submit">Send Transfer</button>
</form>
<div id="legit-result" class="result"></div>
</div>
<!-- Right panel — attacker form with a guessed/fake token -->
<div class="panel" id="attacker-panel">
<h3>Attacker’s Forged Form</h3>
<p class="desc">This form lives on evil.example. It cannot read the real page’s token (same-origin policy blocks it).</p>
<div class="token-display">
<span class="label">Fake token (guessed):</span>
<code id="attacker-token-shown" class="token-val danger"></code>
</div>
<form id="attacker-form" class="form">
<label>To: <input id="attacker-to" type="text" value="Eve" /></label>
<label>Amount ($): <input id="attacker-amount" type="number" value="500" min="1" max="500" /></label>
<button type="submit">Forge Transfer</button>
</form>
<div id="attacker-result" class="result"></div>
</div>
</div>
<div class="log-area">
<div class="log-header">
<span>Server log</span>
<button id="reset-btn" type="button" class="ghost">Clear log</button>
</div>
<ul id="log"></ul>
</div>
/* Base layout and typography */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
h3 { margin: 0 0 .4rem; font-size: 1rem; }
.panel-row { display: flex; gap: .8rem; flex-wrap: wrap; }
.panel { flex: 1 1 220px; border: 1px solid #cdd9e3; border-radius: 10px; padding: .8rem; background: #f4f8fb; }
#attacker-panel { border-color: #e63946; background: #fff5f5; }
/* Token display badge */
.token-display { margin: .4rem 0 .6rem; display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
.label { font-size: .78rem; color: #555; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; }
.token-val { font-size: .72rem; background: #e3edf5; border-radius: 4px; padding: 2px 6px; color: #1d3557; word-break: break-all; }
.token-val.danger { background: #fde8ea; color: #c92f3c; }
/* Form inputs and labels */
.form { display: flex; flex-direction: column; gap: .45rem; }
.form label { display: flex; flex-direction: column; font-size: .82rem; color: #333; gap: .15rem; }
.form input[type="text"], .form input[type="number"] {
padding: .3rem .5rem; border: 1px solid #b0bec5; border-radius: 6px; font-size: .9rem;
}
.desc { font-size: .82rem; color: #555; margin: 0 0 .5rem; line-height: 1.4; }
button { margin-top: .4rem; font: 600 13px system-ui; padding: .4rem .8rem;
border: 1px solid #1d3557; background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
#attacker-panel button[type="submit"] { border-color: #c92f3c; background: #c92f3c; }
button.ghost { background: #fff; color: #1d3557; font-size: .8rem; padding: .25rem .6rem; }
/* Per-panel result messages */
.result { margin-top: .5rem; min-height: 1.3em; font-weight: 600; font-size: .85rem; }
.result.ok { color: #0a7d33; }
.result.bad { color: #c92f3c; }
/* Server log list at the bottom */
.log-area { margin-top: .8rem; border: 1px solid #cdd9e3; border-radius: 8px; background: #fff; }
.log-header { display: flex; align-items: center; justify-content: space-between; padding: .4rem .7rem; border-bottom: 1px solid #e3edf5; font-size: .8rem; font-weight: 700; color: #1d3557; text-transform: uppercase; letter-spacing: .04em; }
#log { list-style: none; margin: 0; padding: .3rem .7rem .5rem; max-height: 150px; overflow-y: auto; }
#log li { font-size: .78rem; font-family: ui-monospace, monospace; padding: .18rem 0; border-bottom: 1px solid #f0f4f7; }
#log li.ok { color: #0a7d33; }
#log li.bad { color: #c92f3c; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Shared secret — known only to the server. In a real app this would
// be an env variable, never exposed to the browser.
const SECRET = 'kipu-demo-secret-2024';
const SESSION_ID = 'user-session-abc123';
// Token = simpleHash(sessionId + ’:’ + SECRET). In production
// use HMAC-SHA256. The key point: the token is bound to the session.
function simpleHash(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = (h * 0x01000193) >>> 0;
}
return h.toString(16).padStart(8, '0');
}
function generateToken(sessionId) {
return simpleHash(sessionId + ':' + SECRET);
}
function verifyToken(tokenFromRequest, sessionId) {
return tokenFromRequest === generateToken(sessionId);
}
// Issue a CSRF token for the legitimate page load
const legitimateToken = generateToken(SESSION_ID);
document.getElementById('legit-token').textContent = legitimateToken;
document.getElementById('legit-csrf').value = legitimateToken;
// The attacker guesses a fake token — they cannot read the real one (same-origin policy)
const attackerFakeToken = 'deadbeef';
document.getElementById('attacker-token-shown').textContent = attackerFakeToken;
function addLog(msg, cls) {
const li = document.createElement('li');
li.textContent = msg;
li.className = cls;
const log = document.getElementById('log');
log.insertBefore(li, log.firstChild);
}
function setResult(id, msg, cls) {
const el = document.getElementById(id);
el.textContent = msg;
el.className = 'result ' + cls;
}
// Legitimate form submit — token matches
document.getElementById('legit-form').addEventListener('submit', function(e) {
e.preventDefault();
const token = document.getElementById('legit-csrf').value;
const to = document.getElementById('legit-to').value;
const amount = document.getElementById('legit-amount').value;
const valid = verifyToken(token, SESSION_ID);
if (valid) {
setResult('legit-result', i18nFmt('Transfer of $%amount% to %to% approved.', { to, amount }), 'ok');
addLog(i18nFmt('[OK] Transfer $%amount% -> %to% | token verified', { to, amount }), 'ok');
} else {
setResult('legit-result', 'Token mismatch — request rejected.', 'bad');
addLog('[REJECT] Token mismatch', 'bad');
}
});
// Attacker form submit — token does NOT match (blocked)
document.getElementById('attacker-form').addEventListener('submit', function(e) {
e.preventDefault();
const token = attackerFakeToken;
const to = document.getElementById('attacker-to').value;
const amount = document.getElementById('attacker-amount').value;
const valid = verifyToken(token, SESSION_ID);
if (valid) {
setResult('attacker-result', i18nFmt('Forged transfer of $%amount% to %to% succeeded! (token was correct — should not happen)', { to, amount }), 'ok');
addLog(i18nFmt('[WARN] Forged request accepted — token %token% matched (unexpected)', { to, amount }), 'ok');
} else {
setResult('attacker-result', 'Blocked! Token invalid — CSRF attack stopped.', 'bad');
addLog(i18nFmt('[BLOCK] Forged request rejected — token ’%token%’ invalid', { token }), 'bad');
}
});
// Clear logs and result messages
document.getElementById('reset-btn').addEventListener('click', function() {
document.getElementById('log').innerHTML = '';
setResult('legit-result', '', '');
setResult('attacker-result', '', '');
});
Notice the asymmetry: the server does not need to store every token individually. It signs the token with a secret key (here simulated with a simple hash), so verification is instant and stateless — the server just checks whether HMAC(session,secret)=token. A forged request from another origin cannot read the token from the page (the same-origin policy blocks it), so the attacker is stuck.
Comments
Loading comments...