The demo below simulates a login form backed by a tiny in-memory "database". The server builds its query in one of two modes — vulnerable (string concatenation) or safe (parameterized query). Try both and observe how the constructed query changes.
<!-- Login form panel with mode switch, inputs, query preview, and result -->
<div class="panel">
<h3 class="panel-title">SQL Injection Demo</h3>
<div class="mode-row">
<span class="mode-label">Mode:</span>
<button id="btn-vuln" class="mode-btn active" type="button">Vulnerable</button>
<button id="btn-safe" class="mode-btn" type="button">Safe (parameterized)</button>
</div>
<label class="field-label" for="inp-user">Username</label>
<input id="inp-user" type="text" class="inp" placeholder="e.g. alice or ' OR '1'='1" autocomplete="off" />
<label class="field-label" for="inp-pass">Password</label>
<input id="inp-pass" type="password" class="inp" placeholder="e.g. hunter2" autocomplete="off" />
<button id="btn-login" type="button" class="btn-login">Log in</button>
<div class="sql-box">
<span class="sql-label">Query sent to database</span>
<pre id="sql-preview" class="sql-pre"></pre>
</div>
<div id="result" class="result"></div>
</div>
/* Styles for the SQL injection demo panel */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; background: transparent; }
.panel { max-width: 480px; margin: 0 auto; padding: 1rem; }
.panel-title { margin: 0 0 .8rem; font-size: 1.1rem; font-weight: 700; }
.mode-row { display: flex; align-items: center; gap: .5rem; margin-bottom: .9rem; flex-wrap: wrap; }
.mode-label { font-size: .85rem; color: #555; flex-shrink: 0; }
.mode-btn { font-size: .82rem; font-weight: 600; padding: .25rem .7rem; border-radius: 6px;
border: 1.5px solid #aaa; background: #f0f2f4; color: #444; cursor: pointer; }
.mode-btn.active.vuln { border-color: #c92f3c; background: #fde8ea; color: #a01c27; }
.mode-btn.active.safe { border-color: #0a7d33; background: #d8f5e3; color: #065c26; }
.field-label { display: block; font-size: .82rem; color: #555; margin: .5rem 0 .2rem; }
.inp { width: 100%; padding: .45rem .6rem; font-size: .95rem; border: 1.5px solid #ccc;
border-radius: 6px; outline: none; }
.inp:focus { border-color: #1d3557; }
.btn-login { margin-top: .75rem; width: 100%; padding: .5rem; font: 600 .95rem system-ui, sans-serif;
background: #1d3557; color: #fff; border: none; border-radius: 8px; cursor: pointer; }
.btn-login:hover { background: #16294a; }
.sql-box { margin-top: .9rem; }
.sql-label { font-size: .78rem; font-weight: 600; color: #666; text-transform: uppercase; letter-spacing: .04em; }
.sql-pre { margin: .3rem 0 0; padding: .55rem .7rem; background: #f4f6f8; border: 1px solid #dde1e6;
border-radius: 6px; font: 400 .8rem/1.5 ui-monospace, monospace; white-space: pre-wrap;
word-break: break-all; color: #333; }
.sql-pre .inject { color: #c92f3c; font-weight: 700; }
.result { margin-top: .8rem; min-height: 2em; font-size: .95rem; font-weight: 600; padding: .45rem .7rem;
border-radius: 6px; display: none; }
.result.ok { display: block; background: #d8f5e3; color: #065c26; border: 1px solid #a3d9b5; }
.result.fail { display: block; background: #fde8ea; color: #a01c27; border: 1px solid #f2b3b8; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Simulated user database — in a real app this would be rows in a SQL table
const DB = [
{ username: 'alice', password: 'hunter2' },
{ username: 'bob', password: 'qwerty' }
];
// vulnerable=true means the app concatenates user input directly into SQL
let vulnerable = true;
const inpUser = document.getElementById('inp-user');
const inpPass = document.getElementById('inp-pass');
const sqlPre = document.getElementById('sql-preview');
const resultEl = document.getElementById('result');
const btnVuln = document.getElementById('btn-vuln');
const btnSafe = document.getElementById('btn-safe');
// Build and show the SQL query that would be sent to the database
function updatePreview() {
const u = inpUser.value;
const p = inpPass.value;
if (vulnerable) {
// Vulnerable mode — user input pasted directly into the SQL string
const raw = "SELECT * FROM users WHERE username = '" + u + "' AND password = '" + p + "'";
// Highlight injected parts in red so the user can see what changed
const safe_part1 = "SELECT * FROM users WHERE username = '";
const safe_part2 = "' AND password = '";
const safe_part3 = "'";
let html = escHtml(safe_part1);
html += '<span class="inject">' + escHtml(u) + '</span>';
html += escHtml(safe_part2);
html += '<span class="inject">' + escHtml(p) + '</span>';
html += escHtml(safe_part3);
sqlPre.innerHTML = html;
} else {
// Safe mode — fixed template with ? placeholders; actual values sent separately
sqlPre.textContent = "SELECT * FROM users WHERE username = ? AND password = ?";
}
}
function escHtml(s) {
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
}
// Simulate the login check against the in-memory database
function doLogin() {
const u = inpUser.value;
const p = inpPass.value;
let granted = false;
if (vulnerable) {
// Build the raw SQL string exactly as a vulnerable server would
const query = "SELECT * FROM users WHERE username = '" + u + "' AND password = '" + p + "'";
// Simulate the database evaluating the (possibly injected) query
granted = evalQuery(query);
} else {
// Safe mode — compare literal strings, no query building
granted = DB.some(r => r.username === u && r.password === p);
}
if (granted) {
resultEl.className = 'result ok';
resultEl.textContent = '✓ Access granted — you are logged in!';
} else {
resultEl.className = 'result fail';
resultEl.textContent = '✗ Wrong credentials — access denied.';
}
}
// Simulate a minimal SQL WHERE evaluator: handles the normal case and the classic OR '1'='1 tautology
function evalQuery(sql) {
// Try to extract literal username/password from the WHERE clause
const m = sql.match(/username\s*=\s*'([^']*)'\s*AND\s*password\s*=\s*'([^']*)'/i);
if (!m) {
// No literal match — check for the classic injection tautologies
return /OR\s+'?1'?\s*=\s*'?1'?/i.test(sql) || /OR\s+1\s*=\s*1/i.test(sql);
}
return DB.some(r => r.username === m[1] && r.password === m[2]);
}
// Switch between vulnerable and safe mode on button click
btnVuln.addEventListener('click', () => {
vulnerable = true;
btnVuln.classList.add('active', 'vuln');
btnSafe.classList.remove('active', 'safe');
resultEl.className = 'result';
updatePreview();
});
btnSafe.addEventListener('click', () => {
vulnerable = false;
btnSafe.classList.add('active', 'safe');
btnVuln.classList.remove('active', 'vuln');
resultEl.className = 'result';
updatePreview();
});
inpUser.addEventListener('input', updatePreview);
inpPass.addEventListener('input', updatePreview);
document.getElementById('btn-login').addEventListener('click', doLogin);
// Initial state — mark vulnerable button and render the first preview
btnVuln.classList.add('vuln');
updatePreview();
<!-- Login form panel with mode switch, inputs, query preview, and result -->
<div class="panel">
<h3 class="panel-title">SQL Injection Demo</h3>
<div class="mode-row">
<span class="mode-label">Mode:</span>
<button id="btn-vuln" class="mode-btn active" type="button">Vulnerable</button>
<button id="btn-safe" class="mode-btn" type="button">Safe (parameterized)</button>
</div>
<label class="field-label" for="inp-user">Username</label>
<input id="inp-user" type="text" class="inp" placeholder="e.g. alice or ' OR '1'='1" autocomplete="off" />
<label class="field-label" for="inp-pass">Password</label>
<input id="inp-pass" type="password" class="inp" placeholder="e.g. hunter2" autocomplete="off" />
<button id="btn-login" type="button" class="btn-login">Log in</button>
<div class="sql-box">
<span class="sql-label">Query sent to database</span>
<pre id="sql-preview" class="sql-pre"></pre>
</div>
<div id="result" class="result"></div>
</div>
/* Styles for the SQL injection demo panel */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; background: transparent; }
.panel { max-width: 480px; margin: 0 auto; padding: 1rem; }
.panel-title { margin: 0 0 .8rem; font-size: 1.1rem; font-weight: 700; }
.mode-row { display: flex; align-items: center; gap: .5rem; margin-bottom: .9rem; flex-wrap: wrap; }
.mode-label { font-size: .85rem; color: #555; flex-shrink: 0; }
.mode-btn { font-size: .82rem; font-weight: 600; padding: .25rem .7rem; border-radius: 6px;
border: 1.5px solid #aaa; background: #f0f2f4; color: #444; cursor: pointer; }
.mode-btn.active.vuln { border-color: #c92f3c; background: #fde8ea; color: #a01c27; }
.mode-btn.active.safe { border-color: #0a7d33; background: #d8f5e3; color: #065c26; }
.field-label { display: block; font-size: .82rem; color: #555; margin: .5rem 0 .2rem; }
.inp { width: 100%; padding: .45rem .6rem; font-size: .95rem; border: 1.5px solid #ccc;
border-radius: 6px; outline: none; }
.inp:focus { border-color: #1d3557; }
.btn-login { margin-top: .75rem; width: 100%; padding: .5rem; font: 600 .95rem system-ui, sans-serif;
background: #1d3557; color: #fff; border: none; border-radius: 8px; cursor: pointer; }
.btn-login:hover { background: #16294a; }
.sql-box { margin-top: .9rem; }
.sql-label { font-size: .78rem; font-weight: 600; color: #666; text-transform: uppercase; letter-spacing: .04em; }
.sql-pre { margin: .3rem 0 0; padding: .55rem .7rem; background: #f4f6f8; border: 1px solid #dde1e6;
border-radius: 6px; font: 400 .8rem/1.5 ui-monospace, monospace; white-space: pre-wrap;
word-break: break-all; color: #333; }
.sql-pre .inject { color: #c92f3c; font-weight: 700; }
.result { margin-top: .8rem; min-height: 2em; font-size: .95rem; font-weight: 600; padding: .45rem .7rem;
border-radius: 6px; display: none; }
.result.ok { display: block; background: #d8f5e3; color: #065c26; border: 1px solid #a3d9b5; }
.result.fail { display: block; background: #fde8ea; color: #a01c27; border: 1px solid #f2b3b8; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Simulated user database — in a real app this would be rows in a SQL table
const DB = [
{ username: 'alice', password: 'hunter2' },
{ username: 'bob', password: 'qwerty' }
];
// vulnerable=true means the app concatenates user input directly into SQL
let vulnerable = true;
const inpUser = document.getElementById('inp-user');
const inpPass = document.getElementById('inp-pass');
const sqlPre = document.getElementById('sql-preview');
const resultEl = document.getElementById('result');
const btnVuln = document.getElementById('btn-vuln');
const btnSafe = document.getElementById('btn-safe');
// Build and show the SQL query that would be sent to the database
function updatePreview() {
const u = inpUser.value;
const p = inpPass.value;
if (vulnerable) {
// Vulnerable mode — user input pasted directly into the SQL string
const raw = "SELECT * FROM users WHERE username = '" + u + "' AND password = '" + p + "'";
// Highlight injected parts in red so the user can see what changed
const safe_part1 = "SELECT * FROM users WHERE username = '";
const safe_part2 = "' AND password = '";
const safe_part3 = "'";
let html = escHtml(safe_part1);
html += '<span class="inject">' + escHtml(u) + '</span>';
html += escHtml(safe_part2);
html += '<span class="inject">' + escHtml(p) + '</span>';
html += escHtml(safe_part3);
sqlPre.innerHTML = html;
} else {
// Safe mode — fixed template with ? placeholders; actual values sent separately
sqlPre.textContent = "SELECT * FROM users WHERE username = ? AND password = ?";
}
}
function escHtml(s) {
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
}
// Simulate the login check against the in-memory database
function doLogin() {
const u = inpUser.value;
const p = inpPass.value;
let granted = false;
if (vulnerable) {
// Build the raw SQL string exactly as a vulnerable server would
const query = "SELECT * FROM users WHERE username = '" + u + "' AND password = '" + p + "'";
// Simulate the database evaluating the (possibly injected) query
granted = evalQuery(query);
} else {
// Safe mode — compare literal strings, no query building
granted = DB.some(r => r.username === u && r.password === p);
}
if (granted) {
resultEl.className = 'result ok';
resultEl.textContent = '✓ Access granted — you are logged in!';
} else {
resultEl.className = 'result fail';
resultEl.textContent = '✗ Wrong credentials — access denied.';
}
}
// Simulate a minimal SQL WHERE evaluator: handles the normal case and the classic OR '1'='1 tautology
function evalQuery(sql) {
// Try to extract literal username/password from the WHERE clause
const m = sql.match(/username\s*=\s*'([^']*)'\s*AND\s*password\s*=\s*'([^']*)'/i);
if (!m) {
// No literal match — check for the classic injection tautologies
return /OR\s+'?1'?\s*=\s*'?1'?/i.test(sql) || /OR\s+1\s*=\s*1/i.test(sql);
}
return DB.some(r => r.username === m[1] && r.password === m[2]);
}
// Switch between vulnerable and safe mode on button click
btnVuln.addEventListener('click', () => {
vulnerable = true;
btnVuln.classList.add('active', 'vuln');
btnSafe.classList.remove('active', 'safe');
resultEl.className = 'result';
updatePreview();
});
btnSafe.addEventListener('click', () => {
vulnerable = false;
btnSafe.classList.add('active', 'safe');
btnVuln.classList.remove('active', 'vuln');
resultEl.className = 'result';
updatePreview();
});
inpUser.addEventListener('input', updatePreview);
inpPass.addEventListener('input', updatePreview);
document.getElementById('btn-login').addEventListener('click', doLogin);
// Initial state — mark vulnerable button and render the first preview
btnVuln.classList.add('vuln');
updatePreview();