Skip to main content

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.


For every short link created, the links row contains:

ColumnPurpose
short_codeThe public short ID (e.g. msk)
original_urlThe destination URL
password_hashbcrypt hash (cost 12), only if a password was set
expires_atTimestamp after which the link returns "expired"
delete_tokenRandom 48-char token, shown only to the creator
click_countAnonymous counter, never linked to specific clicks
created_atCreation timestamp
created_ip_hashHMAC-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:

ColumnExample value
link_idforeign key to the link
clicked_attimestamp
ip_hashHMAC-SHA-256(IP, IP_HASH_SECRET), never reversed
referrerhost only, e.g. github.com (no path, no query)
browserfamily name from UA, e.g. Chrome
osfamily name from UA, e.g. Linux
device_typedesktop, 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 (de or en).
  • 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 users table, no sessions table, 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:

  1. Take the requesting IP from X-Forwarded-For (set by Apache) or the socket address.
  2. Compute HMAC-SHA-256(ip, IP_HASH_SECRET).
  3. 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, localhost
  • 10.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:

  1. Generic error messages. The API returns the same 401 for wrong password and non-existent link, preventing enumeration of which short codes are protected.
  2. 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:

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.