Why Build a SAML Capture Server?
Note : This has been built not for malicious purposes, but learning purposes - this application does not modify or play around with your authentication token whatsoever.
This guide demonstrates how to create a SAML honeypot that captures authentication attempts in real-time, providing unprecedented visibility into who is actually clicking links and authenticating to your applications - this does not intercept or alter credentials - this is just live accurate activity data that requires the user to click on a link in order to initiate the user authentication flow flow
If you have email protection that tries to visit links on behalf of the user to assess if they’re malicious, then, in this scenario, you are still protected from false positives because the sandbox service will not have your credentials, so it will simply fail to write a valid sign in log.
Visuals of the Solution
This will give you some visuals of the solution, this is the default page that advises of configuration setup required:
This is the ACL URL when you visit it without a valid SAML request:
This is the log file when you use the "view log file" option, so see a live view of the logs:
If you test the SAML process it will look like this, notice the this-is-a-test in the relay state:
You will then see the "test" data in the log file as below:
[2025-08-11 06:56:03] === POST DATA ===
[2025-08-11 06:56:03] SAMLResponse received (length: 88 characters)
[2025-08-11 06:56:03] SAMLResponse decoded preview: <saml:Assertion><test>Demo SAML ResVnse</test></saml:Assertion>...
[2025-08-11 06:56:03] POST RelayState: this-is-a-test
[2025-08-11 06:56:03] === GET DATA ===
[2025-08-11 06:56:03] === END SAML ACS ACCESS ===
[2025-08-11 06:56:03]
Traditional email and SMS campaigns lack reliable attribution - you might know someone opened an email or clicked a link, but you can't definitively prove who actually engaged with your content, in many of these tests, if you have the URL that link will be unique to the user - this means if you click on the link, or the actual user clicks on the link, the original user will get the reporting against them.
SAML : Accurate Data
By creating an Azure AD Enterprise Application connected to a custom capture server, you can:
- Guarantee Identity Verification: When users click your link, they authenticate with their real Azure AD credentials
- Capture Detailed Logs: Record exact usernames, email addresses, timestamps, and browser details
- Monitor Curious Behavior: See who clicks suspicious links without understanding the consequences
- Track Engagement: Get definitive proof of who interacted with your content
Real-World Applications
Security Awareness Testing: Send employees a link to a "new company portal" and see who blindly authenticates without verification
Phishing Simulation: Test if users will authenticate credentials on unfamiliar domains
Threat Intelligence: Monitor for credential stuffing or suspicious authentication patterns
How It Works
- Create Enterprise Application in Azure AD with your custom ACS endpoint
- Generate User Access URL from Azure AD (looks legitimate to users)
- Embed in Shortened URL using services like bit.ly for stealth
- Send via Email/SMS to target audience
- Capture Real Credentials when users authenticate
- Analyze Results through detailed server logs
What does the user authentication flow look like?
We have two options here, you can either have a conditional access policy that will apply and require MFA everywhere, or with the other option, you have “trusted” named locations so MFA is not required when you access this application from certain networks
Strict MFA
If you have MFA enabled for all your services and applications and you have not “trusted” named locations - good move that is the most secure option, if this is the case, then this is how the User authentication flow will work with this application:
Relaxed MFA
If you have opted for the relaxed MFA security model where MFA is required only when coming from outside your trusted networks, that is good news for this particular scenario because it means you are not asked to authenticate your credentials with MFA.
Unfortunately, it does mean you’ve removed an extra layer of defense between you and something malicious, this makes it even more security problem if all your phones and devices then obtain an address that makes it look like they’re inside the network - say with a VPN solution or an iOS/Android app that forces traffic through your corporate proxy that’s in the same trusted network.
If you have chosen this option, then users will very rarely get the MFA prompt and this extra layer of defense will only apply when they’re outside your trusted locations like at home.
MFA Is no longer an optional security feature it’s now a requirement and overriding it because it’s a bit of an inconvenience is not a good place to be from a security point of view - people need to get used to doing MFA authentication as the more you do it, the more you get used to it.
MFA : Is that just not the extra inconvenience?
No, absolutely not, I have heard countless people tell me they’ve lost access to the email/service accounts because they haven’t turned MFA on, when you get to this point, I find it very hard to feel sorry for the person because essentially it’s their own fault.
I have had to then listen to a list of reasons why MFA was not enabled - I always think to myself if you just enabled MFA and didn’t find excuses not to enable it you wouldn’t be having this conversation - if you have services that do not support MFA - do not store any personal or sensitive information on those services unless you’re willing to lose it or have it made publicly available!
MFA is also no longer that secure anymore, and there are many exploits to bypass that now, you really need to be considering passkeys or phishing resistant hardware tokens like FIDO2..
Anyway, I digress…
The user authentication flow without MFA because you’re in a trusted location looks like this:
Implementation time
You are not able to do this with a simple static webpage because that will only support the HTTP GET command - this is not sufficient to successfully authenticate with SAML you need HTTP POST, let’s get cracking.
This guide will walk you through setting up a secure Linux server with Nginx that captures SAML login details from Azure AD Enterprise Applications, remember you need to substitute SAML.a6n.co.uk for the actual address of your website
Step 1: Create Ubuntu VM in Azure
Warning : I will be using SSH password authentication as I will not be exposing SSH to the public Internet, if you were looking to expose SSH to the Internet, you should only be using passkeys and even then, it should be limited to trusted addresses on your NSG - allowing a virtual machine with password based SSH is not a good idea!!!
- Azure Portal → "Create a resource" → "Virtual Machine"
- Configure:
- Image: Ubuntu Server 22.04 LTS
- Size: Standard B2s (recommended for SSL)
- Authentication: Password (I will connect with Twingate)
- Username:
azureuser
- Password: Create a strong password
- Networking tab:
- Public IP: Create new
- Public inbound ports: HTTP (80), HTTPS (443)
- Create the VM
Step 2: Point Your Domain to the VM
- Get your VM's public IP from Azure Portal
- In your DNS provider:
- Create A record:
saml.a6n.co.uk
→YOUR-VM-PUBLIC-IP
- Wait 5-10 minutes for DNS propagation
- Create A record:
Step 3: Connect and Install Software
# SSH into your VM
ssh azureuser@YOUR-VM-IP
# Update system
sudo apt update && sudo apt upgrade -y
# Install Nginx, PHP, and SSL tools
sudo apt install nginx php-fpm certbot python3-certbot-nginx -y
# Start services
sudo systemctl start nginx
sudo systemctl start php8.1-fpm
sudo systemctl enable nginx
sudo systemctl enable php8.1-fpm
Step 4: Configure Nginx
# Remove default nginx config
sudo rm /etc/nginx/sites-enabled/default
# Create initial Nginx site configuration (HTTP only)
cat << 'EOF' | sudo tee /etc/nginx/sites-available/saml
server {
listen 80;
server_name saml.a6n.co.uk;
root /var/www/saml;
index index.php index.html;
location / {
try_files $uri $uri/ =404;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
}
}
EOF
# Enable the site
sudo ln -s /etc/nginx/sites-available/saml /etc/nginx/sites-enabled/
# Test nginx configuration
sudo nginx -t
# Reload nginx
sudo systemctl reload nginx
Step 5: Create Web Directory and Files
# Create the web directory
sudo mkdir -p /var/www/saml
Create CSS File
cat << 'EOF' | sudo tee /var/www/saml/style.css
body {
font-family: Arial, sans-serif;
text-align: center;
padding: 50px;
background-color: #f5f5f5;
}
.container {
background: white;
padding: 40px;
border-radius: 10px;
max-width: 600px;
margin: 0 auto;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.btn {
background-color: #0078d4;
color: white;
padding: 12px 24px;
text-decoration: none;
border-radius: 5px;
margin: 10px;
display: inline-block;
}
.btn.secondary {
background-color: #6c757d;
}
.btn:hover {
opacity: 0.9;
}
.info {
background-color: #e7f3ff;
padding: 15px;
border-radius: 5px;
margin: 20px 0;
font-size: 14px;
text-align: left;
}
.success {
color: #28a745;
}
.timestamp {
color: #666;
font-size: 0.9em;
margin-top: 20px;
}
.logs {
background-color: #000;
color: #00ff00;
padding: 20px;
border-radius: 5px;
font-family: monospace;
font-size: 12px;
height: 500px;
overflow-y: scroll;
white-space: pre-wrap;
}
textarea {
width: 100%;
height: 150px;
padding: 10px;
margin: 10px 0;
border: 1px solid #ddd;
border-radius: 5px;
font-family: monospace;
}
input[type="submit"] {
background: #28a745;
color: white;
padding: 12px 24px;
border: none;
border-radius: 5px;
cursor: pointer;
}
input[type="text"] {
width: 100%;
padding: 10px;
margin: 10px 0;
border: 1px solid #ddd;
border-radius: 5px;
}
code {
background-color: #f8f9fa;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
EOF
Create Main Page
cat << 'EOF' | sudo tee /var/www/saml/index.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SAML Login Demo</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>SAML Login Demo</h1>
<p>This server captures login details when users authenticate via Azure AD.</p>
<div class="info">
<strong>Azure AD Configuration URLs:</strong><br><br>
<strong>Identifier (Entity ID):</strong><br>
<code>https://saml.a6n.co.uk/metadata.xml</code><br><br>
<strong>Reply URL (ACS URL):</strong><br>
<code>https://saml.a6n.co.uk/saml-acs.php</code><br><br>
<strong>Sign-on URL:</strong><br>
<em>Leave blank for IdP-initiated flow</em>
</div>
<a href="test-form.php" class="btn">Test SAML Form</a>
<a href="view-logs.php" class="btn secondary">View Recent Logs</a>
</div>
</body>
</html>
EOF
Create SAML ACS Handler (The Core Capture Script)
cat << 'EOF' | sudo tee /var/www/saml/saml-acs.php
<?php
$timestamp = date('Y-m-d H:i:s');
$logFile = '/var/log/nginx/saml_capture.log';
function logData($message) {
global $logFile, $timestamp;
file_put_contents($logFile, "[$timestamp] $message\n", FILE_APPEND | LOCK_EX);
}
logData("=== SAML ACS ENDPOINT ACCESS ===");
logData("Method: " . $_SERVER['REQUEST_METHOD']);
logData("URI: " . $_SERVER['REQUEST_URI']);
logData("Query String: " . ($_SERVER['QUERY_STRING'] ?? 'None'));
// Log all headers
logData("=== HEADERS ===");
foreach (getallheaders() as $name => $value) {
logData("Header $name: $value");
}
logData("=== POST DATA ===");
foreach ($_POST as $key => $value) {
if ($key === 'SAMLResponse') {
logData("SAMLResponse received (length: " . strlen($value) . " characters)");
// Try to decode and extract user info
try {
$decoded = base64_decode($value);
logData("SAMLResponse decoded preview: " . substr($decoded, 0, 500) . "...");
// Extract email if present
if (preg_match('/emailaddress[^>]*>([^<]+)</', $decoded, $matches)) {
logData("Extracted Email: " . $matches[1]);
}
// Extract name if present
if (preg_match('/<saml:NameID[^>]*>([^<]+)<\/saml:NameID>/', $decoded, $matches)) {
logData("Extracted NameID: " . $matches[1]);
}
} catch (Exception $e) {
logData("Error decoding SAMLResponse: " . $e->getMessage());
}
} else {
logData("POST $key: $value");
}
}
logData("=== GET DATA ===");
foreach ($_GET as $key => $value) {
logData("GET $key: $value");
}
logData("=== END SAML ACS ACCESS ===");
logData(""); // Empty line for readability
$hasSamlData = !empty($_POST['SAMLResponse']) || !empty($_GET['SAMLResponse']);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SAML Response Received</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<?php if ($hasSamlData): ?>
<h1 class="success">✓ Your details have been logged, thanks!</h1>
<p>You have successfully signed in and your login details have been captured.</p>
<?php else: ?>
<h1>ACS Endpoint Accessed</h1>
<p>The SAML ACS endpoint was accessed but no SAML response was received.</p>
<p>This might be a direct access or a configuration test.</p>
<?php endif; ?>
<div class="timestamp">Logged at: <?php echo $timestamp; ?></div>
<br>
<a href="view-logs.php" class="btn">View Captured Logs</a>
<a href="index.php" class="btn secondary">← Back to Home</a>
</div>
</body>
</html>
EOF
Create Test Form
cat << 'EOF' | sudo tee /var/www/saml/test-form.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test SAML Response</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Test SAML Response Handler</h1>
<p>This form simulates what Azure AD sends to your server:</p>
<form action="saml-acs.php" method="POST">
<label for="SAMLResponse">SAML Response (Base64):</label>
<textarea name="SAMLResponse" placeholder="Paste SAML response here (or leave empty for demo test)">PHNhbWw6QXNzZXJ0aW9uPjx0ZXN0PkRlbW8gU0FNTCBSZXNWB25zZTwvdGVzdD48L3NhbWw6QXNzZXJ0aW9uPg==</textarea>
<label for="RelayState">Relay State (optional):</label>
<input type="text" name="RelayState" placeholder="Optional relay state">
<br><br>
<a href="index.php" class="btn secondary">← Back</a>
<input type="submit" value="Submit Test SAML Response">
</form>
</div>
</body>
</html>
EOF
Create Log Viewer
cat << 'EOF' | sudo tee /var/www/saml/view-logs.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SAML Logs</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Recent SAML Capture Logs</h1>
<a href="index.php" class="btn secondary">← Back to Home</a>
<a href="view-logs.php" class="btn">Refresh</a>
<br><br>
<div class="logs">
<?php
$logFile = '/var/log/nginx/saml_capture.log';
if (file_exists($logFile) && is_readable($logFile)) {
$logs = file_get_contents($logFile);
if (!empty($logs)) {
echo htmlspecialchars($logs);
} else {
echo "Log file exists but is empty. No captures yet.";
}
} else {
echo "No logs found yet. Try submitting the test form first.\n\n";
echo "Log file location: $logFile\n";
echo "File exists: " . (file_exists($logFile) ? "Yes" : "No") . "\n";
echo "File readable: " . (is_readable($logFile) ? "Yes" : "No") . "\n";
}
?>
</div>
</div>
</body>
</html>
EOF
Create SAML Metadata
cat << 'EOF' | sudo tee /var/www/saml/metadata.xml
<?xml version="1.0" encoding="UTF-8"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://saml.a6n.co.uk/metadata.xml">
<md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</md:NameIDFormat>
<md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</md:NameIDFormat>
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://saml.a6n.co.uk/saml-acs.php" index="1"/>
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://saml.a6n.co.uk/saml-acs.php" index="2"/>
</md:SPSSODescriptor>
</md:EntityDescriptor>
EOF
Step 6: Set Permissions and Create Log File
# Set correct ownership and permissions
sudo chown -R www-data:www-data /var/www/saml
sudo chmod 644 /var/www/saml/*.php
sudo chmod 644 /var/www/saml/*.xml
sudo chmod 644 /var/www/saml/*.css
# Create log file with correct permissions
sudo touch /var/log/nginx/saml_capture.log
sudo chown www-data:www-data /var/log/nginx/saml_capture.log
sudo chmod 644 /var/log/nginx/saml_capture.log
Step 7: Generate SSL Certificate
# Generate SSL certificate with Let's Encrypt
sudo certbot --nginx -d saml.a6n.co.uk
During the process:
- Enter your email address
- Agree to terms of service
- Choose whether to share email (your choice)
- Certbot will automatically configure HTTPS
Step 8: Test Your Server
- Visit:
https://saml.a6n.co.uk
- Verify SSL certificate (should show lock icon)
- Test the form → click "Test SAML Form" and submit
- Check logs → click "View Recent Logs"
Step 9: Create Azure AD Enterprise Application
- Go to Azure Portal → "Azure Active Directory"
- Click "Enterprise applications" → "New application"
- Click "Create your own application"
- Name it (e.g., "SAML Login Capture")
- Select "Integrate any other application you don't find in the gallery"
- Click "Create"
Step 10: Configure SAML Single Sign-On
-
In your new application, click "Single sign-on"
-
Select "SAML"
-
Click "Edit" in Basic SAML Configuration
-
Enter these exact URLs:
- Identifier (Entity ID):
https://saml.a6n.co.uk/metadata.xml
- Reply URL (ACS URL):
https://saml.a6n.co.uk/saml-acs.php
- Sign-on URL: Leave blank (this is crucial for IdP-initiated flow)
- Identifier (Entity ID):
-
Click "Save"
Test the Complete Flow
- Go to your Enterprise Application → Overview
- Click "Test this application"
- Sign in with your Azure AD credentials
- You should see: "Your details have been logged, thanks!"
- Check captured data:
https://saml.a6n.co.uk/view-logs.php
View Recent Logs
# View last 50 lines
sudo tail -50 /var/log/nginx/saml_capture.log
# Watch logs in real-time
sudo tail -f /var/log/nginx/saml_capture.log
Clear Log File
# Clear logs to start fresh
sudo truncate -s 0 /var/log/nginx/saml_capture.log
Check Services
# Check nginx status
sudo systemctl status nginx
# Check PHP-FPM status
sudo systemctl status php8.1-fpm
What’s Captured?
When users authenticate through Azure AD, you'll capture:
- User email address and name
- Complete SAML response with all attributes
- Timestamps of authentication
- User's IP address and browser details
- Any custom attributes configured in Azure AD
This setup creates a production-ready SAML login capture system that will securely log all authentication attempts from your Azure AD Enterprise Application.