StegoCrypt: Image Steganography Tool
Hide AES-256 encrypted messages inside images using LSB steganography.
Introduction
30 Second Summary
People have been hiding secret messages inside ordinary objects for centuries. Today, you can do the same thing digitally by embedding invisible data into the pixels of an image.
In this project, you will build a browser-based tool called StegoCrypt that hides encrypted messages inside images. It combines two independent security layers: concealment through pixel manipulation, and encryption through a passphrase.
What You'll Build
StegoCrypt is a client-side web application that hides encrypted messages inside images using LSB steganography and AES-256-GCM encryption, with zero external dependencies.
By the end of this project, you'll have:
- A working steganography engine that embeds secret messages into image pixels using LSB encoding.
- AES-256-GCM encryption powered by the browser's built-in Web Crypto API for dual-layer security.
- A polished, dark-themed UI with drag-and-drop image upload, a capacity indicator, and PNG download of the stego image.
- Secret Mission: A pixel diff visualization that proves steganographic changes are invisible to the human eye.
Are there any prerequisites?
This project assumes basic familiarity with HTML, CSS, and JavaScript. Everything runs directly in your browser with no additional installations or build steps.
Set Up the Project Structure
Every web application starts with a solid foundation of files. StegoCrypt is a single-page app built with three files: one for structure (HTML), one for appearance (CSS), and one for logic (JavaScript).
In this step, you will create the project folder, set up all three files, and build out the full HTML skeleton that defines every UI element your steganography tool needs.
In this step, get ready to:
- Create the project folder and empty files.
- Build the HTML skeleton with all UI elements.
- Verify the page loads in a browser.
Create the project folder and files
You need a dedicated folder to keep all three project files together. You will create this folder on your Desktop so it is easy to find.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir stegocrypt
cd stegocrypt
- Create the three empty project files by running the command for your operating system:
macOS
touch index.html style.css app.js
Windows
New-Item -ItemType File -Path "index.html", "style.css", "app.js"
- Confirm the files exist by running:
ls
You should see index.html, style.css, and app.js listed in the output.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the stegocrypt folder, then click Open.
You should now see the three files in the Explorer sidebar on the left.
Don't see your files in the sidebar?
Make sure you selected the stegocrypt folder itself, not the Desktop. The Explorer panel should show STEGOCRYPT as the workspace name at the top.
If you accidentally opened the wrong folder, go to File then Open Folder again and select stegocrypt on your Desktop.
Help me troubleshoot this
Set up the HTML skeleton
The HTML file defines every element in your steganography tool's interface. This includes the mode toggle, image upload zone, text inputs, and a hidden canvas for pixel manipulation.
- In the Explorer sidebar, click index.html to open it in the editor.
- Copy the following code and paste it into index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>StegoCrypt - Image Steganography Tool</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header>
<h1>StegoCrypt</h1>
<p class="subtitle">Hide encrypted messages inside images</p>
</header>
<div class="mode-toggle">
<button id="encode-tab" class="tab active" data-mode="encode">Encode</button>
<button id="decode-tab" class="tab" data-mode="decode">Decode</button>
</div>
<main>
<div class="upload-zone" id="upload-zone">
<p>Drop an image here or click to upload</p>
<input type="file" id="file-input" accept="image/*" hidden>
</div>
<div id="image-preview-container" class="hidden">
<img id="image-preview" alt="Uploaded image preview">
<p id="capacity-info" class="capacity-info"></p>
</div>
<div id="encode-controls">
<textarea id="message-input" placeholder="Enter your secret message..." rows="4"></textarea>
<input type="password" id="passphrase-input" placeholder="Passphrase (optional - adds AES-256 encryption)">
<button id="encode-btn" class="action-btn" disabled>Encode Message</button>
</div>
<div id="decode-controls" class="hidden">
<input type="password" id="passphrase-decode" placeholder="Passphrase (if encrypted)">
<button id="decode-btn" class="action-btn" disabled>Decode Message</button>
</div>
<div id="output-section" class="hidden">
<h3 id="output-title">Output</h3>
<div id="output-content"></div>
<button id="download-btn" class="action-btn hidden">Download Stego Image</button>
</div>
<div id="status" class="status"></div>
</main>
</div>
<canvas id="canvas" hidden></canvas>
<script src="app.js"></script>
</body>
</html>
What does this HTML do?
- The mode-toggle div holds two buttons that switch between Encode and Decode views.
- The upload-zone is a drag-and-drop area for loading images. A hidden file-input element handles the actual file selection.
- The encode-controls section has a textarea for the secret message and a passphrase field for optional encryption.
- The canvas element is hidden from view but essential for reading and writing image pixel data with JavaScript.
- Elements with class="hidden" will be toggled visible later through CSS and JavaScript.
- Save index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).
See a blank page when you open the file later?
Double check that you have the closing </html> tag at the very end of the file. A missing closing tag can prevent the browser from rendering content.
Also confirm that style.css and app.js are in the same folder as index.html. Even though they are empty, the browser will log console errors if it cannot find them.
Help me fix my HTML file
✔️ Awesome, I've got everything!
Great. Double check you have saved index.html before moving on.
ⓧ I'd like to double check the full code
Here is the complete index.html file for comparison:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>StegoCrypt - Image Steganography Tool</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header>
<h1>StegoCrypt</h1>
<p class="subtitle">Hide encrypted messages inside images</p>
</header>
<div class="mode-toggle">
<button id="encode-tab" class="tab active" data-mode="encode">Encode</button>
<button id="decode-tab" class="tab" data-mode="decode">Decode</button>
</div>
<main>
<div class="upload-zone" id="upload-zone">
<p>Drop an image here or click to upload</p>
<input type="file" id="file-input" accept="image/*" hidden>
</div>
<div id="image-preview-container" class="hidden">
<img id="image-preview" alt="Uploaded image preview">
<p id="capacity-info" class="capacity-info"></p>
</div>
<div id="encode-controls">
<textarea id="message-input" placeholder="Enter your secret message..." rows="4"></textarea>
<input type="password" id="passphrase-input" placeholder="Passphrase (optional - adds AES-256 encryption)">
<button id="encode-btn" class="action-btn" disabled>Encode Message</button>
</div>
<div id="decode-controls" class="hidden">
<input type="password" id="passphrase-decode" placeholder="Passphrase (if encrypted)">
<button id="decode-btn" class="action-btn" disabled>Decode Message</button>
</div>
<div id="output-section" class="hidden">
<h3 id="output-title">Output</h3>
<div id="output-content"></div>
<button id="download-btn" class="action-btn hidden">Download Stego Image</button>
</div>
<div id="status" class="status"></div>
</main>
</div>
<canvas id="canvas" hidden></canvas>
<script src="app.js"></script>
</body>
</html>
Verify your setup in the browser
StegoCrypt runs entirely in the browser with no server required. You can open the HTML file directly to confirm everything is wired up.
- In the VS Code Explorer sidebar, right-click index.html.
- Select Reveal in Finder (macOS) or Reveal in File Explorer (Windows).
- Double-click index.html in the file browser to open it in your default web browser.
You should see the page load with unstyled text including the heading "StegoCrypt", the Encode and Decode buttons, a text area, and password fields.
Why does it look plain?
The page has no styling yet because style.css is empty. You will add the dark cybersecurity theme in the next step. The important thing right now is that all the HTML elements render without errors.
Page is completely blank?
Open your browser's developer console by pressing F12 and check the Console tab for red error messages.
The most common cause is a typo in the HTML tags. Compare your file against the full code in the double-check tab above.
Help me debug my blank page
Your project structure is ready and the HTML skeleton is in place. Next up, you will style the interface with a dark cybersecurity theme and wire up the mode toggle and image upload interactivity.
Build the UI and Styling
Your HTML skeleton is in place, but right now it looks like a plain, unstyled page. A cybersecurity tool needs to look the part.
In this step, you'll give StegoCrypt a dark, terminal-inspired theme and wire up the interactive elements so users can toggle modes, upload images, and see how much data they can hide.
In this step, get ready to:
- Apply a dark cybersecurity theme with cyan accent colors.
- Wire up mode switching, image uploads, and capacity calculation.
- Verify the UI responds to interaction in the browser.
Style the dark cybersecurity theme
The visual design sets the tone for StegoCrypt. You'll use a dark background with cyan/green accents that evoke a terminal aesthetic. The upload zone will give visual feedback on drag-and-drop, and buttons will glow on hover.
- Open style.css in VS Code and paste the following styles:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #0a0e17;
color: #e0e0e0;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 2rem;
}
.container {
max-width: 700px;
width: 100%;
}
header {
text-align: center;
margin-bottom: 2rem;
}
header h1 {
font-size: 2.5rem;
color: #00ffc8;
text-shadow: 0 0 20px rgba(0, 255, 200, 0.3);
}
.subtitle {
color: #888;
margin-top: 0.5rem;
}
.mode-toggle {
display: flex;
gap: 0;
margin-bottom: 2rem;
border-radius: 8px;
overflow: hidden;
border: 1px solid #1e2a3a;
}
.tab {
flex: 1;
padding: 0.8rem 1.5rem;
border: none;
background: #111923;
color: #888;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s;
}
.tab.active {
background: #00ffc8;
color: #0a0e17;
font-weight: bold;
}
.upload-zone {
border: 2px dashed #1e2a3a;
border-radius: 12px;
padding: 3rem;
text-align: center;
cursor: pointer;
transition: all 0.3s;
margin-bottom: 1.5rem;
}
.upload-zone:hover,
.upload-zone.dragover {
border-color: #00ffc8;
background: rgba(0, 255, 200, 0.05);
}
#image-preview-container {
text-align: center;
margin-bottom: 1.5rem;
}
#image-preview {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
border: 1px solid #1e2a3a;
}
.capacity-info {
color: #00ffc8;
margin-top: 0.5rem;
font-size: 0.9rem;
}
textarea,
input[type="password"] {
width: 100%;
padding: 1rem;
margin-bottom: 1rem;
background: #111923;
border: 1px solid #1e2a3a;
border-radius: 8px;
color: #e0e0e0;
font-size: 1rem;
resize: vertical;
}
textarea:focus,
input[type="password"]:focus {
outline: none;
border-color: #00ffc8;
}
.action-btn {
width: 100%;
padding: 1rem;
background: #00ffc8;
color: #0a0e17;
border: none;
border-radius: 8px;
font-size: 1.1rem;
font-weight: bold;
cursor: pointer;
transition: all 0.3s;
}
.action-btn:hover:not(:disabled) {
background: #00e6b5;
box-shadow: 0 0 20px rgba(0, 255, 200, 0.3);
}
.action-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
#output-section {
margin-top: 2rem;
padding: 1.5rem;
background: #111923;
border-radius: 12px;
border: 1px solid #1e2a3a;
}
#output-section h3 {
color: #00ffc8;
margin-bottom: 1rem;
}
#output-content {
word-break: break-word;
line-height: 1.6;
}
#download-btn {
margin-top: 1rem;
}
.status {
margin-top: 1rem;
padding: 0.8rem;
border-radius: 8px;
text-align: center;
font-size: 0.9rem;
}
.status.error {
background: rgba(255, 50, 50, 0.1);
border: 1px solid #ff3232;
color: #ff6b6b;
}
.status.success {
background: rgba(0, 255, 200, 0.1);
border: 1px solid #00ffc8;
color: #00ffc8;
}
.hidden {
display: none;
}
What does this CSS do?
The theme builds a dark, terminal-style aesthetic using a near-black background (#0a0e17) and cyan (#00ffc8) as the accent color.
- .tab.active highlights whichever mode (Encode or Decode) the user has selected.
- .upload-zone.dragover lights up cyan when a user drags an image over the drop area, giving instant visual feedback.
- .action-btn:disabled dims buttons that aren't ready yet, preventing premature clicks.
- .hidden is a utility class that toggling JavaScript will add and remove to show or hide sections.
- Save style.css.
- Refresh index.html in your browser. You should see a dark page with a glowing cyan "StegoCrypt" heading, a mode toggle bar, a dashed upload zone, and styled input fields.
Page still looks unstyled?
- Make sure you saved the file as style.css (not styles.css). The HTML references style.css exactly.
- Confirm style.css is in the same folder as index.html. If it's nested in a subfolder, the link won't resolve.
- Try a hard refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows) to bypass the browser cache.
Why isn't my dark theme showing up in the browser?
Wire up the UI interactivity
The page looks great, but nothing responds to clicks yet. You need JavaScript to handle mode switching, image uploads, and the capacity calculation that tells users how many characters they can hide.
- Open app.js and add the canvas context, state variables, and DOM references by pasting this code:
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let currentImage = null;
let currentMode = 'encode';
let stegoDataURL = null;
// DOM elements
const uploadZone = document.getElementById('upload-zone');
const fileInput = document.getElementById('file-input');
const imagePreview = document.getElementById('image-preview');
const imagePreviewContainer = document.getElementById('image-preview-container');
const capacityInfo = document.getElementById('capacity-info');
const messageInput = document.getElementById('message-input');
const passphraseInput = document.getElementById('passphrase-input');
const passphraseDecodeInput = document.getElementById('passphrase-decode');
const encodeBtn = document.getElementById('encode-btn');
const decodeBtn = document.getElementById('decode-btn');
const encodeControls = document.getElementById('encode-controls');
const decodeControls = document.getElementById('decode-controls');
const outputSection = document.getElementById('output-section');
const outputTitle = document.getElementById('output-title');
const outputContent = document.getElementById('output-content');
const downloadBtn = document.getElementById('download-btn');
const statusEl = document.getElementById('status');
What does this code do?
This block caches every DOM element you'll interact with into named constants. Grabbing references once at the top avoids repeated getElementById calls and keeps the rest of your code clean.
The canvas and ctx references are for the hidden Canvas element where pixel manipulation will happen in a later step.
You won't see a visible change yet. The next chunk makes the mode toggle respond to clicks.
- Add the mode toggle logic directly below the DOM references in app.js:
// Mode toggle
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentMode = tab.dataset.mode;
if (currentMode === 'encode') {
encodeControls.classList.remove('hidden');
decodeControls.classList.add('hidden');
} else {
encodeControls.classList.add('hidden');
decodeControls.classList.remove('hidden');
}
outputSection.classList.add('hidden');
clearStatus();
});
});
What does this code do?
When the user clicks a tab, this listener removes the active class from both tabs, then adds it back to the one that was clicked. The CSS you wrote earlier highlights the active tab in cyan.
It also shows or hides the appropriate control panel (message input for Encode, passphrase-only for Decode) using the hidden class.
- Save app.js and refresh your browser.
- Click the Decode tab. The tab should turn cyan, and the message textarea should disappear, replaced by only the passphrase field and Decode Message button.
Tabs not switching?
- Open your browser's developer console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows) and look for red error messages.
- Make sure you pasted the mode toggle code BELOW the DOM references block. If encodeControls is referenced before it's declared, you'll get an error.
Why aren't my Encode/Decode tabs responding to clicks?
- Now add the file upload handling, image loading, and status helper functions below the mode toggle code in app.js:
// File upload
uploadZone.addEventListener('click', () => fileInput.click());
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('dragover');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('dragover');
});
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
loadImage(file);
}
});
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) loadImage(file);
});
function loadImage(file) {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
currentImage = img;
imagePreview.src = e.target.result;
imagePreviewContainer.classList.remove('hidden');
uploadZone.classList.add('hidden');
const maxChars = Math.floor((img.width * img.height * 3) / 8) - 4;
capacityInfo.textContent = `Capacity: ~${maxChars.toLocaleString()} characters`;
encodeBtn.disabled = false;
decodeBtn.disabled = false;
clearStatus();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
// Status messages
function showStatus(message, type) {
statusEl.textContent = message;
statusEl.className = 'status ' + type;
}
function clearStatus() {
statusEl.textContent = '';
statusEl.className = 'status';
}
What does this code do?
- The upload zone responds to both clicks (opens a file picker) and drag-and-drop. The dragover listener adds the dragover CSS class so the border turns cyan while dragging.
- loadImage() reads the file as a data URL, displays it as a preview, and calculates the maximum characters that can be hidden. The formula floor(width * height * 3 / 8) - 4 comes from using 3 color channels per pixel (R, G, B), with 8 bits per character, minus 4 bytes reserved for the message length header.
- showStatus() and clearStatus() manage the feedback bar at the bottom. The mode toggle already calls clearStatus() when switching tabs.
- Save app.js and refresh your browser.
- Click the dashed upload zone. A file picker should open. Select any image from your computer.
- You should see the image preview appear and a cyan "Capacity" line showing how many characters the image can hold.
Image not loading or no capacity shown?
- Check the console for errors. A common mistake is a typo in one of the getElementById strings (they must match the HTML id attributes exactly).
- Make sure you selected an actual image file (PNG, JPG, etc.). The drop handler filters by file.type.startsWith('image/') and ignores non-image files silently.
Help me debug why my image upload isn't working in StegoCrypt.
✔️ Awesome, I've got everything!
Great. Double check you've saved both style.css and app.js before moving on.
ⓧ I'd like to double check the full code
Here is the complete style.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #0a0e17;
color: #e0e0e0;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 2rem;
}
.container {
max-width: 700px;
width: 100%;
}
header {
text-align: center;
margin-bottom: 2rem;
}
header h1 {
font-size: 2.5rem;
color: #00ffc8;
text-shadow: 0 0 20px rgba(0, 255, 200, 0.3);
}
.subtitle {
color: #888;
margin-top: 0.5rem;
}
.mode-toggle {
display: flex;
gap: 0;
margin-bottom: 2rem;
border-radius: 8px;
overflow: hidden;
border: 1px solid #1e2a3a;
}
.tab {
flex: 1;
padding: 0.8rem 1.5rem;
border: none;
background: #111923;
color: #888;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s;
}
.tab.active {
background: #00ffc8;
color: #0a0e17;
font-weight: bold;
}
.upload-zone {
border: 2px dashed #1e2a3a;
border-radius: 12px;
padding: 3rem;
text-align: center;
cursor: pointer;
transition: all 0.3s;
margin-bottom: 1.5rem;
}
.upload-zone:hover,
.upload-zone.dragover {
border-color: #00ffc8;
background: rgba(0, 255, 200, 0.05);
}
#image-preview-container {
text-align: center;
margin-bottom: 1.5rem;
}
#image-preview {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
border: 1px solid #1e2a3a;
}
.capacity-info {
color: #00ffc8;
margin-top: 0.5rem;
font-size: 0.9rem;
}
textarea,
input[type="password"] {
width: 100%;
padding: 1rem;
margin-bottom: 1rem;
background: #111923;
border: 1px solid #1e2a3a;
border-radius: 8px;
color: #e0e0e0;
font-size: 1rem;
resize: vertical;
}
textarea:focus,
input[type="password"]:focus {
outline: none;
border-color: #00ffc8;
}
.action-btn {
width: 100%;
padding: 1rem;
background: #00ffc8;
color: #0a0e17;
border: none;
border-radius: 8px;
font-size: 1.1rem;
font-weight: bold;
cursor: pointer;
transition: all 0.3s;
}
.action-btn:hover:not(:disabled) {
background: #00e6b5;
box-shadow: 0 0 20px rgba(0, 255, 200, 0.3);
}
.action-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
#output-section {
margin-top: 2rem;
padding: 1.5rem;
background: #111923;
border-radius: 12px;
border: 1px solid #1e2a3a;
}
#output-section h3 {
color: #00ffc8;
margin-bottom: 1rem;
}
#output-content {
word-break: break-word;
line-height: 1.6;
}
#download-btn {
margin-top: 1rem;
}
.status {
margin-top: 1rem;
padding: 0.8rem;
border-radius: 8px;
text-align: center;
font-size: 0.9rem;
}
.status.error {
background: rgba(255, 50, 50, 0.1);
border: 1px solid #ff3232;
color: #ff6b6b;
}
.status.success {
background: rgba(0, 255, 200, 0.1);
border: 1px solid #00ffc8;
color: #00ffc8;
}
.hidden {
display: none;
}
Here is the complete app.js so far:
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let currentImage = null;
let currentMode = 'encode';
let stegoDataURL = null;
// DOM elements
const uploadZone = document.getElementById('upload-zone');
const fileInput = document.getElementById('file-input');
const imagePreview = document.getElementById('image-preview');
const imagePreviewContainer = document.getElementById('image-preview-container');
const capacityInfo = document.getElementById('capacity-info');
const messageInput = document.getElementById('message-input');
const passphraseInput = document.getElementById('passphrase-input');
const passphraseDecodeInput = document.getElementById('passphrase-decode');
const encodeBtn = document.getElementById('encode-btn');
const decodeBtn = document.getElementById('decode-btn');
const encodeControls = document.getElementById('encode-controls');
const decodeControls = document.getElementById('decode-controls');
const outputSection = document.getElementById('output-section');
const outputTitle = document.getElementById('output-title');
const outputContent = document.getElementById('output-content');
const downloadBtn = document.getElementById('download-btn');
const statusEl = document.getElementById('status');
// Mode toggle
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentMode = tab.dataset.mode;
if (currentMode === 'encode') {
encodeControls.classList.remove('hidden');
decodeControls.classList.add('hidden');
} else {
encodeControls.classList.add('hidden');
decodeControls.classList.remove('hidden');
}
outputSection.classList.add('hidden');
clearStatus();
});
});
// File upload
uploadZone.addEventListener('click', () => fileInput.click());
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('dragover');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('dragover');
});
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
loadImage(file);
}
});
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) loadImage(file);
});
function loadImage(file) {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
currentImage = img;
imagePreview.src = e.target.result;
imagePreviewContainer.classList.remove('hidden');
uploadZone.classList.add('hidden');
const maxChars = Math.floor((img.width * img.height * 3) / 8) - 4;
capacityInfo.textContent = `Capacity: ~${maxChars.toLocaleString()} characters`;
encodeBtn.disabled = false;
decodeBtn.disabled = false;
clearStatus();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
// Status messages
function showStatus(message, type) {
statusEl.textContent = message;
statusEl.className = 'status ' + type;
}
function clearStatus() {
statusEl.textContent = '';
statusEl.className = 'status';
}
Verify the full UI flow
Time for a quick end-to-end check to make sure both the styling and interactivity work together.
- Refresh index.html in your browser.
- Click the Decode tab. The tab should highlight cyan and the controls should switch to show only the passphrase field and Decode Message button.
- Click the Encode tab to switch back.
- Click the dashed upload zone (or drag an image onto it). After selecting an image, you should see the image preview and a cyan capacity line like "Capacity: ~750,000 characters" (the exact number depends on your image size).
- Confirm the Encode Message button is no longer dimmed. It should be fully clickable now that an image is loaded.
Why does capacity vary by image?
Each pixel contributes 3 bits of hidden data (one from each of R, G, B). A 1000x1000 pixel image has 3,000,000 usable bits, which is 375,000 characters. Larger images can hide longer messages.
Your tool looks and feels like a real cybersecurity application. Next up, you'll build the core steganography engine that actually hides messages inside image pixels.
Implement the LSB Steganography Engine
Your UI looks great, but clicking those buttons does nothing yet. The core challenge of steganography is hiding data inside something that already exists, so no one knows it's there.
In this step, you'll build the LSB steganography engine that hides messages inside image pixels. LSB stands for Least Significant Bit. Every pixel color value (0-255) has a last bit that, if flipped, changes the color by only 1 shade. That's completely invisible to the human eye, but your code can read those tiny changes back out perfectly.
In this step, get ready to:
- Build the encoding function that embeds binary data into image pixel channels.
- Build the decoding function that extracts hidden messages from a stego image.
- Test a full encode-decode round-trip to confirm messages survive the process.
Build the encoding function
The encoding function is the heart of StegoCrypt. It takes an image and a message, converts the message into individual bits, and tucks each bit into the least significant bit of a pixel's color channel.
What is LSB steganography?
Every pixel in an image has Red, Green, and Blue values between 0 and 255. In binary, 255 is 11111111. The last digit (the least significant bit) only changes the color by 1 shade. Flipping it is invisible to the eye, but your code can detect it perfectly.
By using the R, G, and B channels (skipping alpha/transparency), you get 3 usable bits per pixel. A 1000x1000 image gives you roughly 375,000 characters of hidden capacity.
- In app.js, find the // Status messages comment near the bottom of your file.
- Add the LSB steganography engine functions above that comment by pasting this code:
// LSB Steganography Engine
function encodeMessage(img, message) {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const msgBinary = messageToBinary(message);
const lengthBinary = message.length.toString(2).padStart(32, '0');
const fullBinary = lengthBinary + msgBinary;
const maxBits = Math.floor(data.length * 3 / 4);
if (fullBinary.length > maxBits) {
throw new Error('Message too large for this image');
}
let bitIndex = 0;
for (let i = 0; i < data.length && bitIndex < fullBinary.length; i++) {
// Skip alpha channel (every 4th byte starting from index 3)
if ((i + 1) % 4 === 0) continue;
data[i] = (data[i] & 0xFE) | parseInt(fullBinary[bitIndex], 10);
bitIndex++;
}
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL('image/png');
}
function messageToBinary(message) {
let binary = '';
for (let i = 0; i < message.length; i++) {
binary += message.charCodeAt(i).toString(2).padStart(8, '0');
}
return binary;
}
What does this code do?
- drawImage() paints the uploaded image onto the hidden <canvas> element so JavaScript can access its raw pixel data.
- getImageData() returns a flat array of pixel values in RGBA order (Red, Green, Blue, Alpha for each pixel).
- A 32-bit length header is prepended to the message bits. This tells the decoder exactly how many characters to extract later.
- The loop skips every 4th byte (the alpha channel) and replaces the LSB of each R, G, B value with one bit of the message using (data[i] & 0xFE) | bit.
- putImageData() writes the modified pixels back, and toDataURL('image/png') exports the result as a lossless PNG.
Why PNG and not JPEG?
PNG uses lossless compression, so every single bit survives. JPEG uses lossy compression that would destroy the hidden LSB data. Your input image can be any format (the canvas converts it), but the output must always be PNG.
Now you need to connect this engine to the Encode button. The handler validates the input, calls encodeMessage(), and displays the result.
- Find the // LSB Steganography Engine comment you just added.
- Add the button handlers above that comment by pasting this code:
// Encode button
encodeBtn.addEventListener('click', async () => {
const message = messageInput.value.trim();
if (!message) {
showStatus('Please enter a message to hide.', 'error');
return;
}
if (!currentImage) {
showStatus('Please upload an image first.', 'error');
return;
}
const maxChars = Math.floor((currentImage.width * currentImage.height * 3) / 8) - 4;
let payload = message;
if (payload.length > maxChars) {
showStatus(`Message too long. Max ${maxChars} characters for this image.`, 'error');
return;
}
try {
stegoDataURL = encodeMessage(currentImage, payload);
outputSection.classList.remove('hidden');
outputTitle.textContent = 'Encoded Image';
outputContent.innerHTML = '<img src="' + stegoDataURL + '" style="max-width:100%;border-radius:8px;">';
downloadBtn.classList.remove('hidden');
showStatus('Message encoded successfully!', 'success');
} catch (err) {
showStatus('Encoding failed: ' + err.message, 'error');
}
});
// Download button
downloadBtn.addEventListener('click', () => {
if (!stegoDataURL) return;
const link = document.createElement('a');
link.download = 'stegocrypt_output.png';
link.href = stegoDataURL;
link.click();
});
What does this code do?
- The handler first validates that both a message and an image exist before attempting to encode.
- It calculates the maximum character capacity for the loaded image and rejects messages that are too long.
- On success, it displays the stego image in the output section and reveals the download button.
- The download handler creates a temporary <a> element with the PNG data URL and programmatically clicks it to trigger the browser's save dialog.
- Save app.js.
- Refresh your browser. Upload any image, type a short message (like Hello), and click Encode Message.
- You should see the encoded image appear in the output section, a green success message, and the Download Stego Image button.
Not seeing the encoded image?
- Check the browser console for errors. A typo in the function name (encodeMessage vs encodemessage) will cause a ReferenceError.
- Make sure you placed the engine functions (encodeMessage, messageToBinary) below the button handler code. JavaScript needs the functions defined before they are called.
- Confirm you uploaded an actual image file. Non-image files won't trigger the loadImage flow.
Why isn't my encode button working in StegoCrypt?
Build the decoding function
Decoding reverses the process. It reads the LSBs back out, reassembles them into bytes, and converts those bytes back into readable text.
- In app.js, find the encodeMessage function in the LSB engine section.
- Add the decodeMessage function between encodeMessage and messageToBinary by pasting this code:
function decodeMessage(img) {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// Extract 32-bit length header first
let bits = '';
let bitIndex = 0;
for (let i = 0; i < data.length && bitIndex < 32; i++) {
if ((i + 1) % 4 === 0) continue;
bits += (data[i] & 1).toString();
bitIndex++;
}
const msgLength = parseInt(bits, 2);
if (msgLength <= 0 || msgLength > 1000000) {
throw new Error('No hidden message found or image is corrupted');
}
// Read the full message bits (header + message)
const totalBits = 32 + (msgLength * 8);
bits = '';
bitIndex = 0;
for (let i = 0; i < data.length && bitIndex < totalBits; i++) {
if ((i + 1) % 4 === 0) continue;
bits += (data[i] & 1).toString();
bitIndex++;
}
// Skip the 32-bit header, convert remaining bits to text
const messageBits = bits.substring(32);
let message = '';
for (let i = 0; i < messageBits.length; i += 8) {
const byte = messageBits.substring(i, i + 8);
if (byte.length === 8) {
message += String.fromCharCode(parseInt(byte, 2));
}
}
return message;
}
What does this code do?
- It first reads 32 bits from the LSBs to recover the message length. This is the header that encodeMessage prepended.
- If the length is nonsensical (zero, negative, or over 1 million), it throws an error. This catches cases where the image has no hidden data.
- It then re-reads the full bit stream (header + message), strips the header, and converts every 8 bits back into a character using String.fromCharCode().
Now wire the decode button to this function.
- Find the // Download button comment in your button handlers section.
- Add the decode button handler above it (between the encode handler and download handler) by pasting this code:
// Decode button
decodeBtn.addEventListener('click', async () => {
if (!currentImage) {
showStatus('Please upload a stego image first.', 'error');
return;
}
try {
let extracted = decodeMessage(currentImage);
outputSection.classList.remove('hidden');
outputTitle.textContent = 'Hidden Message';
outputContent.textContent = extracted;
downloadBtn.classList.add('hidden');
showStatus('Message decoded successfully!', 'success');
} catch (err) {
showStatus('Decoding failed: ' + err.message, 'error');
}
});
What does this code do?
- It calls decodeMessage() with the currently loaded image and displays the extracted text in the output section.
- The download button is hidden during decoding because the learner is extracting text, not creating a new image.
- If the image has no hidden message (or is corrupted), the catch block shows a friendly error.
- Save app.js.
Seeing 'No hidden message found'?
- If you're testing decode with a normal image (not one you encoded), this error is expected. Only stego images produced by your encoder contain hidden data.
- Make sure decodeMessage is placed between encodeMessage and messageToBinary in the engine section. The file order matters for readability, not execution, but keeping it consistent avoids confusion.
Why is my decode function returning garbage text or throwing errors?
Test the encode/decode round-trip
Time to prove the full steganography cycle works end-to-end. You'll encode a message into an image, download it, then decode it back.
- Refresh your browser to start fresh.
- Make sure the Encode tab is active.
- Upload any PNG or JPG image from your computer.
- Type a short test message in the text area, like StegoCrypt works!.
- Leave the passphrase field empty for now.
- Click Encode Message.
- Click Download Stego Image to save stegocrypt_output.png to your computer.
- Switch to the Decode tab.
- Upload the stegocrypt_output.png file you just downloaded.
- Click Decode Message.
You should see your original message (StegoCrypt works!) appear in the output section with a green success status.
Why does the stego image look identical to the original?
That's the whole point of steganography. Changing only the least significant bit of each color channel shifts the value by at most 1 (for example, from 142 to 143). Your eyes cannot distinguish that difference. The image looks completely untouched, but your decoder can read the hidden bits perfectly.
Decoded message looks like garbage?
- Make sure you downloaded and re-uploaded the stego image (the encoded output), not the original image you started with.
- If you took a screenshot of the stego image instead of downloading it, the screenshot tool may have re-compressed it as JPEG, destroying the hidden data. Use the Download Stego Image button to get the proper PNG.
- Check that you didn't accidentally resize or crop the image between encoding and decoding. Any pixel modification breaks the hidden data.
My decoded message is garbled or doesn't match what I encoded.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file, then move on to the next step.
ⓧ I'd like to double check the full code
Here's the complete app.js file as it should look at the end of this step:
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let currentImage = null;
let currentMode = 'encode';
let stegoDataURL = null;
// DOM elements
const uploadZone = document.getElementById('upload-zone');
const fileInput = document.getElementById('file-input');
const imagePreview = document.getElementById('image-preview');
const imagePreviewContainer = document.getElementById('image-preview-container');
const capacityInfo = document.getElementById('capacity-info');
const messageInput = document.getElementById('message-input');
const passphraseInput = document.getElementById('passphrase-input');
const passphraseDecodeInput = document.getElementById('passphrase-decode');
const encodeBtn = document.getElementById('encode-btn');
const decodeBtn = document.getElementById('decode-btn');
const encodeControls = document.getElementById('encode-controls');
const decodeControls = document.getElementById('decode-controls');
const outputSection = document.getElementById('output-section');
const outputTitle = document.getElementById('output-title');
const outputContent = document.getElementById('output-content');
const downloadBtn = document.getElementById('download-btn');
const statusEl = document.getElementById('status');
// Mode toggle
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentMode = tab.dataset.mode;
if (currentMode === 'encode') {
encodeControls.classList.remove('hidden');
decodeControls.classList.add('hidden');
} else {
encodeControls.classList.add('hidden');
decodeControls.classList.remove('hidden');
}
outputSection.classList.add('hidden');
clearStatus();
});
});
// File upload
uploadZone.addEventListener('click', () => fileInput.click());
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('dragover');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('dragover');
});
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
loadImage(file);
}
});
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) loadImage(file);
});
function loadImage(file) {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
currentImage = img;
imagePreview.src = e.target.result;
imagePreviewContainer.classList.remove('hidden');
uploadZone.classList.add('hidden');
const maxChars = Math.floor((img.width * img.height * 3) / 8) - 4;
capacityInfo.textContent = `Capacity: ~${maxChars.toLocaleString()} characters`;
encodeBtn.disabled = false;
decodeBtn.disabled = false;
clearStatus();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
// Encode button
encodeBtn.addEventListener('click', async () => {
const message = messageInput.value.trim();
if (!message) {
showStatus('Please enter a message to hide.', 'error');
return;
}
if (!currentImage) {
showStatus('Please upload an image first.', 'error');
return;
}
const maxChars = Math.floor((currentImage.width * currentImage.height * 3) / 8) - 4;
let payload = message;
if (payload.length > maxChars) {
showStatus(`Message too long. Max ${maxChars} characters for this image.`, 'error');
return;
}
try {
stegoDataURL = encodeMessage(currentImage, payload);
outputSection.classList.remove('hidden');
outputTitle.textContent = 'Encoded Image';
outputContent.innerHTML = '<img src="' + stegoDataURL + '" style="max-width:100%;border-radius:8px;">';
downloadBtn.classList.remove('hidden');
showStatus('Message encoded successfully!', 'success');
} catch (err) {
showStatus('Encoding failed: ' + err.message, 'error');
}
});
// Decode button
decodeBtn.addEventListener('click', async () => {
if (!currentImage) {
showStatus('Please upload a stego image first.', 'error');
return;
}
try {
let extracted = decodeMessage(currentImage);
outputSection.classList.remove('hidden');
outputTitle.textContent = 'Hidden Message';
outputContent.textContent = extracted;
downloadBtn.classList.add('hidden');
showStatus('Message decoded successfully!', 'success');
} catch (err) {
showStatus('Decoding failed: ' + err.message, 'error');
}
});
// Download button
downloadBtn.addEventListener('click', () => {
if (!stegoDataURL) return;
const link = document.createElement('a');
link.download = 'stegocrypt_output.png';
link.href = stegoDataURL;
link.click();
});
// LSB Steganography Engine
function encodeMessage(img, message) {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const msgBinary = messageToBinary(message);
const lengthBinary = message.length.toString(2).padStart(32, '0');
const fullBinary = lengthBinary + msgBinary;
const maxBits = Math.floor(data.length * 3 / 4);
if (fullBinary.length > maxBits) {
throw new Error('Message too large for this image');
}
let bitIndex = 0;
for (let i = 0; i < data.length && bitIndex < fullBinary.length; i++) {
if ((i + 1) % 4 === 0) continue;
data[i] = (data[i] & 0xFE) | parseInt(fullBinary[bitIndex], 10);
bitIndex++;
}
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL('image/png');
}
function decodeMessage(img) {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
let bits = '';
let bitIndex = 0;
for (let i = 0; i < data.length && bitIndex < 32; i++) {
if ((i + 1) % 4 === 0) continue;
bits += (data[i] & 1).toString();
bitIndex++;
}
const msgLength = parseInt(bits, 2);
if (msgLength <= 0 || msgLength > 1000000) {
throw new Error('No hidden message found or image is corrupted');
}
const totalBits = 32 + (msgLength * 8);
bits = '';
bitIndex = 0;
for (let i = 0; i < data.length && bitIndex < totalBits; i++) {
if ((i + 1) % 4 === 0) continue;
bits += (data[i] & 1).toString();
bitIndex++;
}
const messageBits = bits.substring(32);
let message = '';
for (let i = 0; i < messageBits.length; i += 8) {
const byte = messageBits.substring(i, i + 8);
if (byte.length === 8) {
message += String.fromCharCode(parseInt(byte, 2));
}
}
return message;
}
function messageToBinary(message) {
let binary = '';
for (let i = 0; i < message.length; i++) {
binary += message.charCodeAt(i).toString(2).padStart(8, '0');
}
return binary;
}
// Status messages
function showStatus(message, type) {
statusEl.textContent = message;
statusEl.className = 'status ' + type;
}
function clearStatus() {
statusEl.textContent = '';
statusEl.className = 'status';
}
Your steganography engine is fully operational. Anyone looking at your stego image would have no idea there's a hidden message inside. Next up, you'll add an encryption layer so that even if someone discovers the hidden data, they can't read it without the passphrase.
Add AES-256-GCM Encryption
Your steganography engine hides messages inside image pixels. An attacker who knows LSB encoding, though, could extract the hidden text just as easily as you can.
In this step, you will add a second security layer by encrypting the message before it gets embedded. Even if someone extracts the pixel data, all they will see is unreadable ciphertext unless they know the passphrase.
In this step, get ready to:
- Build an encryption function using the browser's Web Crypto API.
- Build a matching decryption function.
- Wire both functions into the existing encode and decode flow.
Implement the encryption function
The Web Crypto API is built into every modern browser. It provides hardware-accelerated cryptographic operations without any external libraries.
- In app.js, scroll to the bottom of the file and find the // Status messages comment.
- Add the following encryption function directly above that comment:
// AES-256-GCM Encryption via Web Crypto API
async function encryptMessage(message, passphrase) {
const encoder = new TextEncoder();
// Generate random salt and IV for each encryption
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
// Import the passphrase as raw key material for PBKDF2
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(passphrase),
{ name: 'PBKDF2' },
false,
['deriveBits', 'deriveKey']
);
// Derive a 256-bit AES key from the passphrase
const key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt']
);
// Encrypt the message
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv },
key,
encoder.encode(message)
);
// Combine salt + IV + ciphertext into a single array
const combined = new Uint8Array(salt.length + iv.length + new Uint8Array(ciphertext).length);
combined.set(salt, 0);
combined.set(iv, salt.length);
combined.set(new Uint8Array(ciphertext), salt.length + iv.length);
// Base64-encode for safe text storage
return btoa(String.fromCharCode(...combined));
}
What does this code do?
This function transforms a human-readable message into encrypted gibberish that only the correct passphrase can reverse.
- A random 16-byte salt and 12-byte IV (initialization vector) are generated fresh each time, so the same message encrypted twice produces different ciphertext.
- PBKDF2 stretches the passphrase into a strong 256-bit key using 100,000 hashing iterations. This makes brute-force attacks extremely slow.
- AES-256-GCM encrypts the message and also verifies its integrity. If anyone tampers with the ciphertext, decryption will fail rather than silently produce garbage.
- The salt, IV, and ciphertext are packed together and Base64-encoded so the result is safe to store as plain text inside the steganography payload.
Why AES-GCM over other encryption modes?
AES-GCM provides authenticated encryption. Other modes like AES-CBC only encrypt the data. GCM also checks that the data has not been modified in transit.
If someone provides the wrong passphrase, GCM immediately throws an error instead of returning corrupted plaintext. This makes error handling straightforward for StegoCrypt.
You won't see a change in the browser yet. The next chunk adds the decryption function that reverses this process.
Implement the decryption function
Decryption reverses the encryption process. It unpacks the combined payload, re-derives the same key from the passphrase, and decrypts the ciphertext.
- In app.js, add the following function directly below the encryptMessage function you just wrote (still above // Status messages):
async function decryptMessage(encoded, passphrase) {
const decoder = new TextDecoder();
// Base64-decode and convert back to byte array
const combined = Uint8Array.from(atob(encoded), c => c.charCodeAt(0));
// Split the combined payload into its three parts
const salt = combined.slice(0, 16);
const iv = combined.slice(16, 28);
const ciphertext = combined.slice(28);
// Re-derive the same key using the extracted salt
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(passphrase),
{ name: 'PBKDF2' },
false,
['deriveBits', 'deriveKey']
);
const key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);
// Decrypt the ciphertext
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv },
key,
ciphertext
);
return decoder.decode(plaintext);
}
What does this code do?
- It Base64-decodes the payload, then splits the bytes into salt (first 16 bytes), IV (next 12 bytes), and ciphertext (everything remaining).
- It re-derives the exact same encryption key by running the passphrase through PBKDF2 with the same salt and iteration count.
- It decrypts the ciphertext. If the passphrase is wrong, GCM's authentication check will throw an error automatically.
Now you need to wire these two functions into the existing button handlers so encryption is used when the learner provides a passphrase.
- In app.js, scroll up to the encode button handler and find this line inside it:
let payload = message;
if (payload.length > maxChars) {
- Replace that section with the following, which checks for a passphrase and encrypts the message before the length check:
let payload = message;
const passphrase = passphraseInput.value;
if (passphrase) {
try {
payload = await encryptMessage(message, passphrase);
} catch (err) {
showStatus('Encryption failed: ' + err.message, 'error');
return;
}
}
if (payload.length > maxChars) {
Why does encryption happen before the length check?
Encryption adds overhead. The Base64 encoding of salt + IV + ciphertext is longer than the original message. Checking length after encryption ensures the capacity indicator accurately reflects whether the encrypted payload will fit in the image.
- Now scroll down to the decode button handler and find this section:
try {
let extracted = decodeMessage(currentImage);
outputSection.classList.remove('hidden');
- Replace it with the following, which attempts decryption if a passphrase is provided:
try {
let extracted = decodeMessage(currentImage);
const passphrase = passphraseDecodeInput.value;
if (passphrase) {
try {
extracted = await decryptMessage(extracted, passphrase);
} catch (err) {
showStatus('Decryption failed. Wrong passphrase or not encrypted.', 'error');
return;
}
}
outputSection.classList.remove('hidden');
- Save app.js.
Seeing a syntax error in the console?
- Make sure the if (passphrase) block is placed between let payload = message; and if (payload.length > maxChars) in the encode handler. An extra or missing curly brace will break the function.
- Check that both handlers still have async in their arrow function definition. The prior step already added it, but confirm async () => { is present.
Help me debug a syntax error in my encrypt/decrypt wiring code.
Test with encryption enabled
Time to prove that the encryption layer actually works. You will test three scenarios: encoding with a passphrase, decoding with the correct passphrase, and decoding with a wrong passphrase.
- Refresh your browser to reload app.js.
- Upload any image (PNG or JPG).
- Type a short message in the message field, such as Top secret info.
- Type a passphrase in the passphrase field, such as mypassword123.
- Click Encode Message.
- Click Download Stego Image to save the output.
You should see the green success message and an encoded image preview. Now test decoding.
- Click the Decode tab to switch modes.
- Upload the stego image you just downloaded (stegocrypt_output.png).
- Enter the same passphrase (mypassword123) in the passphrase field.
- Click Decode Message.
Your original message should appear in the output section.
- Now test with a wrong passphrase. Upload the same stego image again.
- Type wrongpassword in the passphrase field.
- Click Decode Message.
You should see a red error status that says the decryption failed. This confirms GCM's authentication is working.
Decryption succeeds with any passphrase?
- Make sure you encoded with a passphrase filled in. If the passphrase field was empty during encoding, the message was stored without encryption.
- Confirm you are uploading the stego image (the downloaded file), not the original source image.
Help me figure out why decryption works with any passphrase.
✔️ Awesome, I've got everything!
Great. Double check you have saved app.js before continuing.
ⓧ I'd like to double check the full code
Here is the complete app.js at this point in the project:
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let currentImage = null;
let currentMode = 'encode';
let stegoDataURL = null;
// DOM elements
const uploadZone = document.getElementById('upload-zone');
const fileInput = document.getElementById('file-input');
const imagePreview = document.getElementById('image-preview');
const imagePreviewContainer = document.getElementById('image-preview-container');
const capacityInfo = document.getElementById('capacity-info');
const messageInput = document.getElementById('message-input');
const passphraseInput = document.getElementById('passphrase-input');
const passphraseDecodeInput = document.getElementById('passphrase-decode');
const encodeBtn = document.getElementById('encode-btn');
const decodeBtn = document.getElementById('decode-btn');
const encodeControls = document.getElementById('encode-controls');
const decodeControls = document.getElementById('decode-controls');
const outputSection = document.getElementById('output-section');
const outputTitle = document.getElementById('output-title');
const outputContent = document.getElementById('output-content');
const downloadBtn = document.getElementById('download-btn');
const statusEl = document.getElementById('status');
// Mode toggle
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentMode = tab.dataset.mode;
if (currentMode === 'encode') {
encodeControls.classList.remove('hidden');
decodeControls.classList.add('hidden');
} else {
encodeControls.classList.add('hidden');
decodeControls.classList.remove('hidden');
}
outputSection.classList.add('hidden');
clearStatus();
});
});
// File upload
uploadZone.addEventListener('click', () => fileInput.click());
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('dragover');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('dragover');
});
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
loadImage(file);
}
});
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) loadImage(file);
});
function loadImage(file) {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
currentImage = img;
imagePreview.src = e.target.result;
imagePreviewContainer.classList.remove('hidden');
uploadZone.classList.add('hidden');
const maxChars = Math.floor((img.width * img.height * 3) / 8) - 4;
capacityInfo.textContent = `Capacity: ~${maxChars.toLocaleString()} characters`;
encodeBtn.disabled = false;
decodeBtn.disabled = false;
clearStatus();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
// Encode button
encodeBtn.addEventListener('click', async () => {
const message = messageInput.value.trim();
if (!message) {
showStatus('Please enter a message to hide.', 'error');
return;
}
if (!currentImage) {
showStatus('Please upload an image first.', 'error');
return;
}
const maxChars = Math.floor((currentImage.width * currentImage.height * 3) / 8) - 4;
let payload = message;
const passphrase = passphraseInput.value;
if (passphrase) {
try {
payload = await encryptMessage(message, passphrase);
} catch (err) {
showStatus('Encryption failed: ' + err.message, 'error');
return;
}
}
if (payload.length > maxChars) {
showStatus(`Message too long. Max ${maxChars} characters for this image.`, 'error');
return;
}
try {
stegoDataURL = encodeMessage(currentImage, payload);
outputSection.classList.remove('hidden');
outputTitle.textContent = 'Encoded Image';
outputContent.innerHTML = '<img src="' + stegoDataURL + '" style="max-width:100%;border-radius:8px;">';
downloadBtn.classList.remove('hidden');
showStatus('Message encoded successfully!', 'success');
} catch (err) {
showStatus('Encoding failed: ' + err.message, 'error');
}
});
// Decode button
decodeBtn.addEventListener('click', async () => {
if (!currentImage) {
showStatus('Please upload a stego image first.', 'error');
return;
}
try {
let extracted = decodeMessage(currentImage);
const passphrase = passphraseDecodeInput.value;
if (passphrase) {
try {
extracted = await decryptMessage(extracted, passphrase);
} catch (err) {
showStatus('Decryption failed. Wrong passphrase or not encrypted.', 'error');
return;
}
}
outputSection.classList.remove('hidden');
outputTitle.textContent = 'Hidden Message';
outputContent.textContent = extracted;
downloadBtn.classList.add('hidden');
showStatus('Message decoded successfully!', 'success');
} catch (err) {
showStatus('Decoding failed: ' + err.message, 'error');
}
});
// Download button
downloadBtn.addEventListener('click', () => {
if (!stegoDataURL) return;
const link = document.createElement('a');
link.download = 'stegocrypt_output.png';
link.href = stegoDataURL;
link.click();
});
// LSB Steganography Engine
function encodeMessage(img, message) {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const msgBinary = messageToBinary(message);
const lengthBinary = message.length.toString(2).padStart(32, '0');
const fullBinary = lengthBinary + msgBinary;
const maxBits = Math.floor(data.length * 3 / 4);
if (fullBinary.length > maxBits) {
throw new Error('Message too large for this image');
}
let bitIndex = 0;
for (let i = 0; i < data.length && bitIndex < fullBinary.length; i++) {
if ((i + 1) % 4 === 0) continue;
data[i] = (data[i] & 0xFE) | parseInt(fullBinary[bitIndex], 10);
bitIndex++;
}
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL('image/png');
}
function decodeMessage(img) {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
let bits = '';
let bitIndex = 0;
for (let i = 0; i < data.length && bitIndex < 32; i++) {
if ((i + 1) % 4 === 0) continue;
bits += (data[i] & 1).toString();
bitIndex++;
}
const msgLength = parseInt(bits, 2);
if (msgLength <= 0 || msgLength > 1000000) {
throw new Error('No hidden message found or image is corrupted');
}
const totalBits = 32 + (msgLength * 8);
bits = '';
bitIndex = 0;
for (let i = 0; i < data.length && bitIndex < totalBits; i++) {
if ((i + 1) % 4 === 0) continue;
bits += (data[i] & 1).toString();
bitIndex++;
}
const messageBits = bits.substring(32);
let message = '';
for (let i = 0; i < messageBits.length; i += 8) {
const byte = messageBits.substring(i, i + 8);
if (byte.length === 8) {
message += String.fromCharCode(parseInt(byte, 2));
}
}
return message;
}
function messageToBinary(message) {
let binary = '';
for (let i = 0; i < message.length; i++) {
binary += message.charCodeAt(i).toString(2).padStart(8, '0');
}
return binary;
}
// AES-256-GCM Encryption via Web Crypto API
async function encryptMessage(message, passphrase) {
const encoder = new TextEncoder();
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(passphrase),
{ name: 'PBKDF2' },
false,
['deriveBits', 'deriveKey']
);
const key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt']
);
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv },
key,
encoder.encode(message)
);
const combined = new Uint8Array(salt.length + iv.length + new Uint8Array(ciphertext).length);
combined.set(salt, 0);
combined.set(iv, salt.length);
combined.set(new Uint8Array(ciphertext), salt.length + iv.length);
return btoa(String.fromCharCode(...combined));
}
async function decryptMessage(encoded, passphrase) {
const decoder = new TextDecoder();
const combined = Uint8Array.from(atob(encoded), c => c.charCodeAt(0));
const salt = combined.slice(0, 16);
const iv = combined.slice(16, 28);
const ciphertext = combined.slice(28);
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(passphrase),
{ name: 'PBKDF2' },
false,
['deriveBits', 'deriveKey']
);
const key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv },
key,
ciphertext
);
return decoder.decode(plaintext);
}
// Status messages
function showStatus(message, type) {
statusEl.textContent = message;
statusEl.className = 'status ' + type;
}
function clearStatus() {
statusEl.textContent = '';
statusEl.className = 'status';
}
Your StegoCrypt tool now has dual-layer security. Messages are encrypted before they are hidden, meaning an attacker must both know the steganography technique AND crack the passphrase. Next up, you will polish the user experience and prepare a demo-ready flow.
Polish and Prep for Demo
Your steganography tool can encode, encrypt, decode, and decrypt. The core functionality is solid.
In this final step, you will add inline comments to make your code readable for anyone reviewing it. Then you will systematically test every error case and run a complete demo rehearsal to confirm it all works within 60 seconds.
In this step, get ready to:
- Add inline comments to your steganography and encryption functions.
- Test error handling and the download button.
- Run a full timed demo from start to finish.
Add inline comments to your core functions
Readable code is demo-ready code. Anyone reviewing your project (or future-you) should understand each section at a glance.
- In app.js, scroll to the encodeMessage function.
- Find this line:
const msgBinary = messageToBinary(message);
- Add this comment on the line directly above it:
// Convert message to binary with 32-bit length header
const msgBinary = messageToBinary(message);
- Still inside encodeMessage, find the if ((i + 1) % 4 === 0) continue; line inside the for loop and add a comment above it:
// Skip alpha channel (every 4th byte starting from index 3)
if ((i + 1) % 4 === 0) continue;
- Now scroll down to the decodeMessage function. Find the first let bits = ''; line and add this comment above it:
// Extract 32-bit length header first
let bits = '';
let bitIndex = 0;
- Further down in decodeMessage, find the const messageBits = bits.substring(32); line and add a comment above it:
// Skip the 32-bit header, convert remaining bits to text
const messageBits = bits.substring(32);
- Finally, scroll to the encryptMessage function. Find the line that creates the combined array and add a comment above it:
// Combine salt + iv + ciphertext and Base64 encode
const combined = new Uint8Array(salt.length + iv.length + new Uint8Array(ciphertext).length);
Why bother with comments?
These comments make your code self-documenting. When you demo StegoCrypt to others, they can glance at the source and immediately understand the flow: message becomes binary, binary hides in pixel LSBs, and the salt/IV/ciphertext get packed together before encoding.
- Save app.js.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete app.js file. Compare yours against it:
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let currentImage = null;
let currentMode = 'encode';
let stegoDataURL = null;
// DOM elements
const uploadZone = document.getElementById('upload-zone');
const fileInput = document.getElementById('file-input');
const imagePreview = document.getElementById('image-preview');
const imagePreviewContainer = document.getElementById('image-preview-container');
const capacityInfo = document.getElementById('capacity-info');
const messageInput = document.getElementById('message-input');
const passphraseInput = document.getElementById('passphrase-input');
const passphraseDecodeInput = document.getElementById('passphrase-decode');
const encodeBtn = document.getElementById('encode-btn');
const decodeBtn = document.getElementById('decode-btn');
const encodeControls = document.getElementById('encode-controls');
const decodeControls = document.getElementById('decode-controls');
const outputSection = document.getElementById('output-section');
const outputTitle = document.getElementById('output-title');
const outputContent = document.getElementById('output-content');
const downloadBtn = document.getElementById('download-btn');
const statusEl = document.getElementById('status');
// Mode toggle
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentMode = tab.dataset.mode;
if (currentMode === 'encode') {
encodeControls.classList.remove('hidden');
decodeControls.classList.add('hidden');
} else {
encodeControls.classList.add('hidden');
decodeControls.classList.remove('hidden');
}
outputSection.classList.add('hidden');
clearStatus();
});
});
// File upload
uploadZone.addEventListener('click', () => fileInput.click());
uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
uploadZone.classList.add('dragover');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('dragover');
});
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
loadImage(file);
}
});
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) loadImage(file);
});
function loadImage(file) {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
currentImage = img;
imagePreview.src = e.target.result;
imagePreviewContainer.classList.remove('hidden');
uploadZone.classList.add('hidden');
const maxChars = Math.floor((img.width * img.height * 3) / 8) - 4;
capacityInfo.textContent = `Capacity: ~${maxChars.toLocaleString()} characters`;
encodeBtn.disabled = false;
decodeBtn.disabled = false;
clearStatus();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
// Encode button
encodeBtn.addEventListener('click', async () => {
const message = messageInput.value.trim();
if (!message) {
showStatus('Please enter a message to hide.', 'error');
return;
}
if (!currentImage) {
showStatus('Please upload an image first.', 'error');
return;
}
const maxChars = Math.floor((currentImage.width * currentImage.height * 3) / 8) - 4;
let payload = message;
const passphrase = passphraseInput.value;
if (passphrase) {
try {
payload = await encryptMessage(message, passphrase);
} catch (err) {
showStatus('Encryption failed: ' + err.message, 'error');
return;
}
}
if (payload.length > maxChars) {
showStatus(`Message too long. Max ${maxChars} characters for this image.`, 'error');
return;
}
try {
stegoDataURL = encodeMessage(currentImage, payload);
outputSection.classList.remove('hidden');
outputTitle.textContent = 'Encoded Image';
outputContent.innerHTML = '<img src="' + stegoDataURL + '" style="max-width:100%;border-radius:8px;">';
downloadBtn.classList.remove('hidden');
showStatus('Message encoded successfully!', 'success');
} catch (err) {
showStatus('Encoding failed: ' + err.message, 'error');
}
});
// Decode button
decodeBtn.addEventListener('click', async () => {
if (!currentImage) {
showStatus('Please upload a stego image first.', 'error');
return;
}
try {
let extracted = decodeMessage(currentImage);
const passphrase = passphraseDecodeInput.value;
if (passphrase) {
try {
extracted = await decryptMessage(extracted, passphrase);
} catch (err) {
showStatus('Decryption failed. Wrong passphrase or not encrypted.', 'error');
return;
}
}
outputSection.classList.remove('hidden');
outputTitle.textContent = 'Hidden Message';
outputContent.textContent = extracted;
downloadBtn.classList.add('hidden');
showStatus('Message decoded successfully!', 'success');
} catch (err) {
showStatus('Decoding failed: ' + err.message, 'error');
}
});
// Download button
downloadBtn.addEventListener('click', () => {
if (!stegoDataURL) return;
const link = document.createElement('a');
link.download = 'stegocrypt_output.png';
link.href = stegoDataURL;
link.click();
});
// LSB Steganography Engine
function encodeMessage(img, message) {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// Convert message to binary with 32-bit length header
const msgBinary = messageToBinary(message);
const lengthBinary = message.length.toString(2).padStart(32, '0');
const fullBinary = lengthBinary + msgBinary;
const maxBits = Math.floor(data.length * 3 / 4);
if (fullBinary.length > maxBits) {
throw new Error('Message too large for this image');
}
let bitIndex = 0;
for (let i = 0; i < data.length && bitIndex < fullBinary.length; i++) {
// Skip alpha channel (every 4th byte starting from index 3)
if ((i + 1) % 4 === 0) continue;
data[i] = (data[i] & 0xFE) | parseInt(fullBinary[bitIndex], 10);
bitIndex++;
}
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL('image/png');
}
function decodeMessage(img) {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// Extract 32-bit length header first
let bits = '';
let bitIndex = 0;
for (let i = 0; i < data.length && bitIndex < 32; i++) {
if ((i + 1) % 4 === 0) continue;
bits += (data[i] & 1).toString();
bitIndex++;
}
const msgLength = parseInt(bits, 2);
if (msgLength <= 0 || msgLength > 1000000) {
throw new Error('No hidden message found or image is corrupted');
}
const totalBits = 32 + (msgLength * 8);
bits = '';
bitIndex = 0;
for (let i = 0; i < data.length && bitIndex < totalBits; i++) {
if ((i + 1) % 4 === 0) continue;
bits += (data[i] & 1).toString();
bitIndex++;
}
// Skip the 32-bit header, convert remaining bits to text
const messageBits = bits.substring(32);
let message = '';
for (let i = 0; i < messageBits.length; i += 8) {
const byte = messageBits.substring(i, i + 8);
if (byte.length === 8) {
message += String.fromCharCode(parseInt(byte, 2));
}
}
return message;
}
function messageToBinary(message) {
let binary = '';
for (let i = 0; i < message.length; i++) {
binary += message.charCodeAt(i).toString(2).padStart(8, '0');
}
return binary;
}
// AES-256-GCM Encryption via Web Crypto API
async function encryptMessage(message, passphrase) {
const encoder = new TextEncoder();
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(passphrase),
{ name: 'PBKDF2' },
false,
['deriveBits', 'deriveKey']
);
const key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt']
);
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv },
key,
encoder.encode(message)
);
// Combine salt + iv + ciphertext and Base64 encode
const combined = new Uint8Array(salt.length + iv.length + new Uint8Array(ciphertext).length);
combined.set(salt, 0);
combined.set(iv, salt.length);
combined.set(new Uint8Array(ciphertext), salt.length + iv.length);
return btoa(String.fromCharCode(...combined));
}
async function decryptMessage(encoded, passphrase) {
const decoder = new TextDecoder();
const combined = Uint8Array.from(atob(encoded), c => c.charCodeAt(0));
const salt = combined.slice(0, 16);
const iv = combined.slice(16, 28);
const ciphertext = combined.slice(28);
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(passphrase),
{ name: 'PBKDF2' },
false,
['deriveBits', 'deriveKey']
);
const key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv },
key,
ciphertext
);
return decoder.decode(plaintext);
}
// Status messages
function showStatus(message, type) {
statusEl.textContent = message;
statusEl.className = 'status ' + type;
}
function clearStatus() {
statusEl.textContent = '';
statusEl.className = 'status';
}
Test error handling and the download button
Your code already handles every major error case. Now you will trigger each one deliberately to confirm the user sees clear feedback.
- Refresh index.html in your browser.
- Without uploading an image, click Encode Message.
You should see a red status bar saying "Please enter a message to hide." because the message field is empty.
- Type a short message like test in the message field, then click Encode Message again (still without uploading an image).
The button is disabled until an image is loaded, so nothing happens. The disabled attribute in your HTML prevents clicks until loadImage() enables it.
- Now upload any image (PNG or JPG). You should see the preview appear with a green capacity indicator.
- Clear the message field so it is empty, then click Encode Message.
You should see the red error "Please enter a message to hide." appear in the status bar.
- Type a short message, add a passphrase, and click Encode Message. Confirm you see the green "Message encoded successfully!" status.
- Click Download Stego Image. A file called stegocrypt_output.png should download to your computer.
- Switch to Decode mode. Upload the stegocrypt_output.png file you just downloaded.
- Enter the wrong passphrase and click Decode Message.
You should see the red error "Decryption failed. Wrong passphrase or not encrypted." confirming that wrong passphrases are caught gracefully.
What errors are you testing?
Your encode handler checks three conditions in order: is a message provided, is an image loaded, and is the message within capacity. Each returns early with a specific error message so the user knows exactly what to fix.
The decode handler catches wrong-passphrase errors from the Web Crypto API and displays a human-readable message instead of a cryptic failure.
Not seeing any error messages?
Make sure you saved app.js and refreshed the browser. Unsaved changes will not take effect.
Check your browser's Developer Console (press F12) for JavaScript syntax errors. A missing comma or bracket can prevent the entire script from loading.
Help me debug my StegoCrypt error handling
Run your full demo from start to finish
A great demo shows the entire flow from "ordinary image" to "secret message recovered" in under 60 seconds. This rehearsal confirms everything works smoothly together.
- Refresh index.html to start with a clean slate.
- Start a timer on your phone or watch.
- Upload any image file (PNG or JPG).
- Type a secret message like The meeting is at 3pm.
- Enter a passphrase like demo123.
- Click Encode Message.
- Click Download Stego Image.
- Switch to Decode mode.
- Upload the stegocrypt_output.png file.
- Enter the same passphrase demo123 and click Decode Message.
- Stop your timer.
You should see your original message "The meeting is at 3pm" appear in the output section. The entire flow should take well under 60 seconds.
- Compare the encoded image to your original by opening both side by side. They should look visually identical to the naked eye.
Why does the stego image look identical?
LSB steganography only changes the least significant bit of each color channel. That is a difference of at most 1 out of 255 in each R, G, or B value. Human eyes cannot detect a shift that small.
This is exactly what makes steganography powerful. An attacker does not even know there is a message to look for.
Decoded message looks like gibberish?
If you used a passphrase during encoding, you must enter the exact same passphrase during decoding. Even one extra space will produce a decryption error.
If you see random characters instead of your message, you may have uploaded a different image during decode. Make sure you upload the downloaded stegocrypt_output.png file, not the original.
Help me debug my StegoCrypt decode output
Your StegoCrypt tool is complete and demo-ready. You have a polished, zero-dependency cybersecurity application that demonstrates both concealment and encryption in a single flow. Next up: an optional Secret Mission to add visual proof that your steganography is truly invisible.
Secret mission
Pixel Diff Visualization
Your stego images look identical to the originals to the naked eye. But can you prove it? Add a pixel diff visualization that amplifies the changes by 50x, showing exactly which pixels were modified during encoding. The result is a striking visual proof that LSB steganography is truly imperceptible.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally in your browser, so there are no ongoing costs or cloud resources to worry about.
Decide whether to keep your project files on your machine, or delete them entirely.
Resources you used:
- The stegocrypt project folder on your Desktop (contains index.html, style.css, app.js).
Keep everything running
No action needed. Choose this if you want to keep demoing StegoCrypt or continue experimenting with the code.
- Your stegocrypt folder stays on your Desktop, ready to open in any browser whenever you need it.
- No server or background process is running. The app only activates when you open index.html in a browser.
Pause - I'll come back to this later
Close VS Code and your browser tab to free up memory. Your files remain on disk for next time.
- Close the VS Code window with the stegocrypt folder open.
- Close any browser tabs showing index.html.
- When you want to return, open the stegocrypt folder in VS Code and open index.html in your browser again.
Delete - I don't want to use this again
Remove the project folder entirely to start fresh.
- Close VS Code and any browser tabs showing StegoCrypt.
- Delete the project folder by running this command in your terminal:
macOS
rm -rf ~/Desktop/stegocrypt
Windows
rmdir /s /q %USERPROFILE%\Desktop\stegocrypt
- Confirm the folder is gone by running:
ls ~/Desktop
You should no longer see stegocrypt in the output.
Nice Work!
Nice Work!
You just built a complete browser-based steganography tool from scratch. StegoCrypt demonstrates two independent layers of cybersecurity, and it runs entirely client-side with zero dependencies.
You've learned how to:
- Implement LSB steganography to embed secret messages into image pixels using the Canvas API and binary encoding.
- Layer AES-256-GCM encryption on top of steganography using the browser's built-in Web Crypto API with PBKDF2 key derivation.
- Build a polished, demo-ready cybersecurity application with drag-and-drop upload, capacity indicators, error handling, and PNG download.
- Secret Mission: Added a pixel diff visualization that amplifies and displays which pixels were modified during encoding, proving steganographic changes are invisible to the human eye.
Ready to quiz yourself?