Are Webmentions secure and private?
Understanding the security and privacy implications of Webmentions, including common concerns and how to protect your site and users.
Webmentions are designed with security in mind, but like any web technology, they require proper implementation and understanding to keep your site and users safe.
Security Fundamentals
The Verification Requirement
The most important security feature is mandatory verification:
// ❌ NEVER do this - accepting without verification
app.post('/webmention', (req, res) => {
const { source, target } = req.body;
saveMention(source, target); // Dangerous!
res.send('OK');
});
// ✅ ALWAYS verify
app.post('/webmention', async (req, res) => {
const { source, target } = req.body;
// Accept immediately
res.status(202).send('Accepted');
// Verify asynchronously
const isValid = await verifyLink(source, target);
if (isValid) {
saveMention(source, target);
}
});
This prevents:
- Fake mentions from non-existent pages
- Spam claiming to link when they don't
- Impersonation attempts
Async Processing
Always process Webmentions asynchronously:
Why? Fetching the source URL synchronously creates attack vectors:
// ❌ Vulnerable to slowloris attacks
app.post('/webmention', async (req, res) => {
await fetch(req.body.source); // Attacker can make this slow!
res.send('OK'); // Ties up your server
});
// ✅ Protected
app.post('/webmention', async (req, res) => {
await queue.add(req.body); // Queue it
res.status(202).send('Accepted'); // Return immediately
});
// Process in background worker
queue.process(async (job) => {
await verifyMention(job.data);
});
Common Security Threats
1. SSRF (Server-Side Request Forgery)
Attackers could try to make your server fetch internal URLs:
// Malicious mention
{
source: "http://localhost:6379/", // Redis
target: "https://yoursite.com/post"
}
Protection:
function isPublicUrl(url) {
const parsed = new URL(url);
const hostname = parsed.hostname;
// Block private IPs
const privateRanges = [
/^localhost$/i,
/^127\./,
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[01])\./,
/^192\.168\./,
/^169\.254\./, // Link-local
/^::1$/, // IPv6 localhost
/^fe80::/i, // IPv6 link-local
];
if (privateRanges.some(range => range.test(hostname))) {
throw new Error('Private IP not allowed');
}
// Block cloud metadata endpoints
if (hostname === '169.254.169.254') {
throw new Error('Cloud metadata endpoint blocked');
}
// Only allow HTTP/HTTPS
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('Invalid protocol');
}
return true;
}
2. XSS (Cross-Site Scripting)
Malicious content in mentions:
<!-- Attacker's page -->
<div class="h-entry">
<div class="e-content">
<script>alert('XSS')</script>
</div>
</div>
Protection:
const sanitizeHtml = require('sanitize-html');
function sanitizeContent(html) {
return sanitizeHtml(html, {
allowedTags: ['p', 'br', 'strong', 'em', 'a', 'blockquote', 'code'],
allowedAttributes: {
'a': ['href', 'title']
},
allowedSchemes: ['http', 'https', 'mailto'],
});
}
// When displaying
function displayMention(mention) {
const safeContent = sanitizeContent(mention.content.html);
return `<div class="mention">${safeContent}</div>`;
}
3. URL Validation
Ensure URLs are properly formed:
function validateUrl(url) {
try {
const parsed = new URL(url);
// Must be HTTP(S)
if (!['http:', 'https:'].includes(parsed.protocol)) {
return false;
}
// Must have valid hostname
if (!parsed.hostname || parsed.hostname.length === 0) {
return false;
}
return true;
} catch {
return false;
}
}
4. Rate Limiting
Prevent abuse:
const rateLimit = require('express-rate-limit');
// IP-based limiting
const ipLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20, // 20 requests per window
message: 'Too many requests from this IP',
});
// Domain-based limiting
const domainLimiters = new Map();
function checkDomainLimit(source) {
const domain = new URL(source).hostname;
const limiter = domainLimiters.get(domain) || { count: 0, resetAt: Date.now() + 3600000 };
if (Date.now() > limiter.resetAt) {
limiter.count = 0;
limiter.resetAt = Date.now() + 3600000;
}
limiter.count++;
domainLimiters.set(domain, limiter);
if (limiter.count > 50) { // 50 mentions per hour per domain
throw new Error('Domain rate limit exceeded');
}
}
app.post('/webmention', ipLimiter, handleWebmention);
Privacy Considerations
What Data is Shared?
When you send a Webmention, you share:
- Source URL: The page mentioning them
- Target URL: The page being mentioned
- Public content: Anything on your source page
- Server metadata: IP address, user agent (in HTTP request)
Not shared:
- Private/unpublished content
- Analytics data
- User information (unless on the page)
- Internal site structure
Receiving Webmentions Privately
Some sites don't want to display all mentions publicly:
// Save mention but don't display
async function processMention(source, target, data) {
await db.mentions.insert({
source,
target,
...data,
visibility: 'private', // Only owner sees it
verified: true,
});
}
// API only returns public mentions to visitors
app.get('/api/mentions', (req, res) => {
const isOwner = req.user && req.user.isOwner;
const query = {
target: req.query.target,
verified: true,
};
// Non-owners only see public mentions
if (!isOwner) {
query.visibility = { $ne: 'private' };
}
const mentions = await db.mentions.find(query);
res.json(mentions);
});
Anonymous Mentions
Allow mentions without revealing identity:
// Support anonymous h-card
function parseAuthor(hcard) {
// If no name provided, use "Anonymous"
const name = hcard.properties.name?.[0] || 'Anonymous';
// If no photo, use default avatar
const photo = hcard.properties.photo?.[0] || '/images/default-avatar.png';
// URL is optional
const url = hcard.properties.url?.[0];
return { name, photo, url };
}
Selective Display
Choose which mentions to show:
// Moderation statuses
const STATUSES = {
PENDING: 'pending', // Awaiting review
APPROVED: 'approved', // Public display
SPAM: 'spam', // Hidden spam
PRIVATE: 'private', // Received but not shown
};
// Display policy
function shouldDisplay(mention, context) {
// Never show spam
if (mention.status === 'spam') return false;
// Owners see everything except spam
if (context.isOwner) return true;
// Public only sees approved
return mention.status === 'approved';
}
GDPR and Data Protection
Right to Be Forgotten
Implement deletion:
// Delete endpoint
app.delete('/webmention/:id', requireAuth, async (req, res) => {
await db.mentions.delete({ _id: req.params.id });
res.send('Deleted');
});
// Handle source deletion
async function handleSourceGone(source) {
// When source returns 410 Gone, delete mentions
await db.mentions.updateMany(
{ source },
{ status: 'deleted', deletedAt: new Date() }
);
}
Update Handling
Respect mention updates:
async function updateMention(source, target) {
const existing = await db.mentions.findOne({ source, target });
if (!existing) {
// New mention
await createMention(source, target);
return;
}
// Re-verify and update
const data = await fetchAndParse(source, target);
if (!data) {
// Link removed, delete mention
await db.mentions.updateOne(
{ _id: existing._id },
{ status: 'deleted', deletedAt: new Date() }
);
} else {
// Update existing mention
await db.mentions.updateOne(
{ _id: existing._id },
{ ...data, updatedAt: new Date() }
);
}
}
Data Minimization
Only store necessary data:
// ✅ Minimal storage
{
source: 'https://...',
target: 'https://...',
authorName: 'Alice',
authorPhoto: 'https://...',
content: 'Great post!',
published: '2024-01-15',
type: 'reply',
verified: true,
}
// ❌ Excessive storage
{
// Don't store full HTML
sourceHtml: '<html>...</html>',
// Don't store IP addresses
requesterIp: '192.168...',
// Don't store user agent
userAgent: 'Mozilla...',
// Don't store raw microformats
rawMicroformats: {...},
}
Content Security Policy (CSP)
Protect against XSS when displaying mentions:
<meta http-equiv="Content-Security-Policy"
content="default-src 'self';
connect-src 'self' https://api.webmentions.com;
img-src 'self' https:;
style-src 'self' 'unsafe-inline';
script-src 'self';">
HTTPS Requirements
Always use HTTPS:
// Reject non-HTTPS URLs (optional but recommended)
function requireHttps(url) {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') {
throw new Error('HTTPS required');
}
}
// Or upgrade HTTP to HTTPS automatically
function upgradeToHttps(url) {
return url.replace(/^http:/, 'https:');
}
Vouch for Enhanced Trust
Implement the Vouch extension:
// Require vouch from unknown domains
const trustedDomains = new Set(['indieweb.org', 'example.com']);
async function checkVouch(source, vouch) {
// Vouch must be from trusted domain
const vouchDomain = new URL(vouch).hostname;
if (!trustedDomains.has(vouchDomain)) {
return false;
}
// Vouch page must link to source
const response = await fetch(vouch);
const html = await response.text();
return html.includes(source);
}
app.post('/webmention', async (req, res) => {
const { source, target, vouch } = req.body;
const sourceDomain = new URL(source).hostname;
// Unknown domains need vouch
if (!trustedDomains.has(sourceDomain)) {
if (!vouch || !await checkVouch(source, vouch)) {
return res.status(449).send('Vouch required');
}
}
// Process normally
await processMention(source, target);
res.status(202).send('Accepted');
});
Audit Logging
Track Webmention activity:
async function logWebmention(action, data) {
await db.logs.insert({
action, // 'received', 'verified', 'approved', 'rejected'
timestamp: new Date(),
source: data.source,
target: data.target,
ip: data.ip,
result: data.result,
});
}
// Use in processing
async function processWebmention(req) {
await logWebmention('received', {
source: req.body.source,
target: req.body.target,
ip: req.ip,
});
const verified = await verify(req.body);
await logWebmention('verified', {
...req.body,
result: verified,
});
}
Security Checklist
- Verify all mentions before storing
- Process asynchronously
- Block private IP ranges (SSRF protection)
- Sanitize HTML content (XSS protection)
- Validate URLs properly
- Implement rate limiting
- Use HTTPS exclusively
- Set appropriate CSP headers
- Handle updates and deletions
- Minimize data storage
- Log security events
- Regular security audits
Privacy Checklist
- Respect deletion requests
- Support private mentions
- Allow anonymous mentions
- Implement moderation
- Clear privacy policy
- GDPR compliance
- User consent for data usage
- Secure data storage
Best Practices
- Defense in depth: Multiple security layers
- Principle of least privilege: Only store necessary data
- Fail securely: Reject on error, don't approve
- Regular updates: Keep dependencies current
- Security testing: Regular penetration testing
- Incident response: Plan for security events
Webmentions can be both secure and privacy-respecting when implemented with care. The protocol's design encourages good practices, but proper implementation is essential.
Related FAQs
How do I handle spam Webmentions?
Strategies and techniques for preventing, detecting, and moderating spam Webmentions on your website.
How do I implement Webmentions on my site?
Step-by-step guide to adding Webmention support to your website, from beginner to advanced.
What are microformats and why do they matter for Webmentions?
Learn how microformats add semantic meaning to your content and enable rich Webmention interactions.