Privacy & Security
MSK Shortener is built privacy-first. This page explains exactly what is — and isn't — stored when you create a link or click one.
What is stored per link
For every short link created, the links row contains:
| Column | Purpose |
|---|---|
short_code | The public short ID (e.g. msk) |
original_url | The destination URL |
password_hash | bcrypt hash (cost 12) — only if a password was set |
expires_at | Timestamp after which the link returns "expired" |
delete_token | Random 48-char token, shown only to the creator |
click_count | Anonymous counter, never linked to specific clicks |
created_at | Creation timestamp |
created_ip_hash | HMAC-SHA-256 of the creator's IP — used only for rate limiting |
What is stored per click
Each click on a short link adds an anonymized row to the clicks table:
| Column | Example value |
|---|---|
link_id | foreign key to the link |
clicked_at | timestamp |
ip_hash | HMAC-SHA-256(IP, IP_HASH_SECRET) — never reversed |
referrer | host only, e.g. github.com (no path, no query) |
browser | family name from UA, e.g. Chrome |
os | family name from UA, e.g. Linux |
device_type | desktop, mobile, or tablet |
That's it. No full user agent, no full referrer URL, no plain-text IP, no cookies, no fingerprinting.
Click rows are tied to a link_id via ON DELETE CASCADE — when a link is deleted, all its clicks vanish too.
What is NOT stored
- No plain-text IP addresses. IPs are hashed with HMAC-SHA-256 using a server-side secret (
IP_HASH_SECRET). Without the secret, the hashes cannot be reversed. - No GeoIP / country lookups. The application never queries any geolocation service.
- No analytics. No Google Analytics, no Plausible, no Fathom, no Matomo — nothing.
- No tracking cookies. The only cookie set by MSK Shortener is
MSK_SHORTENER_LOCALE, which stores your language preference (deoren). - No third-party scripts. All assets are served from the same origin.
- No referer logging at the app level beyond the host. The query string and full path are stripped before storage.
- No user accounts / sessions / tokens. There is no
userstable, nosessionstable, no JWT — nothing to leak.
How rate limiting works without IPs
Rate limiting needs to identify "the same client" without storing identifiable information. The flow is:
- Take the requesting IP from
X-Forwarded-For(set by Apache) or the socket address. - Compute
HMAC-SHA-256(ip, IP_HASH_SECRET). - Store the hash in an in-memory sliding window of recent requests.
Because the secret is generated per-installation (openssl rand -hex 32), even an attacker with full database access cannot reverse hashes back to IP addresses.
The in-memory bucket resets on every restart, so even the rate-limit state is short-lived.
SSRF protection
URLs that point at private or loopback addresses are rejected at creation time. This prevents your shortener from being abused as a bounce point into internal networks. Blocked ranges:
127.0.0.0/8(loopback)0.0.0.0,::1,localhost10.0.0.0/8(RFC 1918)172.16.0.0/12(RFC 1918)192.168.0.0/16(RFC 1918)169.254.0.0/16(link-local / cloud metadata)
Only http:// and https:// schemes are accepted — no file://, gopher://, ftp://, etc.
Password protection
Passwords are hashed with bcrypt at cost 12 before storage. The plain-text password is never persisted.
Verification has two safeguards:
- Generic error messages. The API returns the same
401for wrong password and non-existent link — preventing enumeration of which short codes are protected. - Brute-force throttling. Verify attempts are rate-limited to 10 per 5 minutes per IP hash.
Expiration & cleanup
All links can have an expires_at timestamp. The redirect route checks this on every request and shows the expired view if it has passed.
A nightly cron job (scripts/cleanup.ts) runs DELETE FROM links WHERE expires_at < NOW() to physically remove expired rows. The default schedule is 03:30 server time. The ON DELETE CASCADE on the clicks table ensures associated click data is also removed.
Database backups
The included backup.sh script creates a daily SQL dump (default: 03:00) with 14-day retention. Backups contain everything in the database, including hashed IPs and password hashes — but not plain text IPs or passwords.
You are responsible for storing backups securely. Consider:
- Encrypting the dump at rest (
gpg --symmetric) - Sending it off-site (rsync, S3 with SSE, restic to Backblaze, …)
- Restricting filesystem permissions on the backup directory
CSP and security headers
MSK Shortener ships with strict Content Security Policy and other headers via next.config.ts:
Content-Security-Policy: default-src 'self'; ...
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: DENY
Permissions-Policy: camera=(), microphone=(), geolocation=()
The Apache vhost also enforces HTTPS, OCSP stapling, and modern TLS ciphers.
Reporting a vulnerability
Found a security issue? Please report it via:
- GitHub Security Advisories: Submit privately
- Email:
info@msk-scripts.de
Please do not open public GitHub issues for security problems — give us a chance to fix and disclose responsibly.
Open source
MSK Shortener is licensed under AGPL-3.0-or-later. You can read every line of the source on GitHub and verify these claims yourself. If you modify the code and run a modified version as a network service, the AGPL requires you to publish your changes.