A URL shortening with click analytics, QR codes, password protection, and link-limiting built on Hono and Cloudflare Workers.
Shrinko transforms long, clunky URLs into clean, short, brandable links while keeping visitor privacy at the core. It is a full web platform: anonymous visitors can shorten links instantly, generate QR codes, apply password protection, set expiration dates or click limits, and track every click through a per-link analytics dashboard all without creating an account.
Registered users get more: a personal dashboard with aggregate statistics, an activity chart, per-link analytics pages, profile management, and a doubled rate-limit quota. On the backend, the entire service runs as a single Cloudflare Worker written in TypeScript with Hono, backed by Supabase, hardened with input validation, salted-hash cryptography, per-IP rate limiting, and strict security headers. Raw IP addresses are never stored only salted SHA-256 hashes so click analytics remain useful while visitors stay anonymous.
The screenshot shows the Shrinko landing page: the hero shortener with the "Advanced Options" panel (custom alias, password protection, click limit, expiration, and QR generation toggles), the animated link-to-short-link illustration, the feature grid, the analytics showcase, and the footer.
- Features
- Architecture
- Demo
- Case Study
- Installation
- Usage
- Project Structure
- Challenges & Solutions
- Future Improvements
- Technologies Used
- License
- Disclaimer
- Contact
- Shortens any
http/httpsURL with a single request no account required. - Generates 5-character random codes (
a-zA-Z0-9) with a collision-retry loop (up to 1000 attempts) to guarantee uniqueness, or accepts custom aliases (5โ15 alphanumeric characters). - Custom aliases are validated server-side with Zod against pattern rules, a reserved-words list (hundreds of reserved terms, including routes, platform names, and brands), and a profanity filter.
- Every short link returns a one-time monitor key that grants anonymous access to that link's analytics and deletion, without any login.
- Link owners can set a password when shortening; the password is stored only as a salted SHA-256 hash never in plaintext.
- Visiting a protected link serves a dedicated unlock page instead of redirecting.
- Wrong passwords show a clear error on the unlock form; the same form also reports rate-limit warnings (HTTP 429) when the visitor is throttled.
- Correctly unlocked links log the click and redirect (303) exactly like public links.
- Links can be limited by expiration date (days from creation), maximum click count, or both.
- On every visit, the worker checks
expires_atand the accumulated click counter; exhausted links return a 410 "Link Not Available" page instead of redirecting. - The monitor page displays remaining clicks / days left and marks links as
LimitedorExpired, so owners always know the current state.
- Every successful redirect is logged asynchronously with hashed IP, country (via Cloudflare's
cf-ipcountryheader), OS (parsed from the User-Agent), and referrer source (mapped to a known-sources list or extracted domain). - The per-link monitor page shows total clicks, unique clicks (computed from unique IP hashes), day-over-day change, a clicks-over-time Chart.js chart, and a full click history table.
- The same analytics are available as JSON (when the request's
Acceptheader is nottext/html), and authenticated users get a per-link details endpoint with aggregated referrers, countries, browsers, and OS breakdowns.
- Each link receives a random 64-character monitor key; only its salted SHA-256 hash is stored in the database.
GET /:short_url/:monitor_keyopens the analytics page (or JSON) no account needed.DELETE /api/v1/:short_url/:monitor_keypermanently deletes the link again, identity-free by design.- Authenticated users can also view analytics (
/dashboard/analytics/:short_url) and delete links (DELETE /api/v1/my-links/:short_url) through their own account.
GET /api/v1/qr?url={url}renders a PNG QR code server-side (rate-limited to 10 requests/minute/IP).- The landing page can show and download the QR code immediately after shortening; the dashboard offers per-link QR codes through a modal.
- QR responses are cached with
Cache-Control: public, max-age=31536000, immutable.
- Custom registration/login with strict validation (name = 2+ letter words, username = 5โ15 lowercase alphanumeric, password = 8โ20 chars with upper/lower/digit/special), a salted SHA-256 credential hash, and session rotation on every login (a fresh 128-char session token is issued and stored hashed).
- Google sign-in via Supabase's PKCE OAuth flow (the Supabase publishable key is exposed to the browser; the service-role key stays server-side only).
- The dashboard fetches the user's links with per-link unique-click counts, renders aggregate stats (total/unique clicks, links created, protected/limited/expired counts) with today-vs-yesterday deltas, an activity chart, sorting (newest/oldest/most/least clicked/expired), pagination, QR modal, and a delete confirmation dialog.
- Profile page supports inline editing of full name, username, and password all re-validated server-side.
- Every response carries strict security headers: a locked-down Content-Security-Policy, HSTS (preload),
X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy, restrictivePermissions-Policy,Cross-Origin-Opener-Policy, andCross-Origin-Resource-Policy. - A per-IP sliding-window rate limiter (60-second window, in-memory) protects every route: 10 req/min default, 20 req/min authenticated shortening, 10 req/min anonymous shortening, 120 req/min profile reads, 10 req/min profile writes and QR generation. HTML requests get styled 429 pages; API requests get JSON errors.
- User-controlled strings rendered into HTML pass through an HTML-escape helper, and the redirect flow distinguishes not-found (404) from expired (410) links.
- The environment loader validates required secrets at startup and fails fast with explicit messages if anything is missing.
Shrinko runs as a single Cloudflare Worker. Static assets are served through the ASSETS binding, all routes are defined on one Hono app, and every dynamic path flows through shared middleware before reaching route-specific logic.
Browser / API Client
โ
โผ
Cloudflare Worker (Hono app src/index.ts)
โ
โโโ Security Headers Middleware (CSP, HSTS, X-Frame-Options, โฆ)
โโโ Rate-Limit Middleware (per IP ร per route, 60s window)
โ
โโโ Static Assets (ASSETS binding: /public/*, robots.txt,
โ sitemap.xml, favicons)
โโโ Page Routes (/, /login, /register, /dashboard, /profile,
โ /api-docs, /about, /contact, /support,
โ /privacy, /terms, /dashboard/analytics/:code)
โโโ Shorten & Link API (POST /api/v1/shorten,
โ GET+DELETE /api/v1/my-links[/:code][/details])
โโโ Redirect & Monitor (GET /:code, POST /api/v1/:code,
โ GET+DELETE /:code/:monitor_key)
โโโ Auth API (POST /api/v1/register, /api/v1/login,
โ GET+POST /api/v1/profile)
โโโ System API (GET /api(/)v1(/)/health, GET /api/v1/qr)
โ
โผ
Services & Libs (validation, salted-hash crypto, analytics parsing,
link creation, monitor payload assembly)
โ
โผ
Supabase PostgreSQL (users, links, clicks)
โ
โผ
HTTP Response (HTML page / JSON / 302-303 redirect / PNG)
Key design decisions visible in the code:
- Redirect speed over bookkeeping click logging is fired via
executionCtx.waitUntilso the 302 response is never delayed by the insert. - No raw identifiers in storage IPs, session IDs, monitor keys, and passwords are all stored as salted SHA-256 hashes.
- Public pages are server-rendered the UI is embedded in TypeScript page modules with targeted inline JavaScript, keeping the worker self-contained.
- Environment fail-fast
config/env.tsthrows at boot whenSUPABASE_URL,SUPABASE_KEY,SUPABASE_PUBLISHABLE_KEY, orSALTare missing.
A live, deployed instance runs at https://s.usfahmed.dev shorten a link immediately from the landing page, open the returned monitor link for analytics, and explore the public pages (/api-docs, /about, /support, โฆ).
- Live demo: https://s.usfahmed.dev
- API documentation: https://s.usfahmed.dev/api-docs
- Demonstration material (Google Drive): https://drive.google.com/drive/folders/1RLW7IpGTwiVMQaFW1shWiiovfJf1-Afx
Try the anonymous workflow in seconds:
# 1. Shorten a URL
curl -s -X POST https://s.usfahmed.dev/api/v1/shorten \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/very/long/path?utm_source=readme","type":"random","isLimited":false,"isProtected":false}'
# โ {"status":"success","redirect_url":"/ab123/<64-char-monitor-key>"}
# 2. Visit your new short link (redirects with 302)
curl -sI https://s.usfahmed.dev/ab123 | findstr /i "location"
# 3. Open the analytics page returned above every subsequent click appears there.A comprehensive technical article explaining the architecture, data flow, cryptographic mechanisms, security decisions, implementation details, trade-offs, and future improvements behind Shrinko.
- Node.js (โฅ 18) and npm
- A Cloudflare account with the Wrangler CLI available (
npx wrangler --version) - A Supabase project (PostgreSQL) used for
users,links, andclicks
git clone <your-repository-url> Shrinko
cd Shrinkonpm install-
Create the environment file from the template and fill in real values:
cp .env.example .env
Variable Purpose SUPABASE_URLYour Supabase project URL SUPABASE_KEYService-role (secret) key server-side only, never used in page HTML SUPABASE_PUBLISHABLE_KEYPublishable key ( sb_publishable_...) used by the browser Supabase client for Google OAuthSALTA random string of at least 20 characters used to salt all hashes (passwords, sessions, monitor keys, IPs) The worker refuses to start if any of these are missing (
src/config/env.ts). -
Create the database tables in Supabase (the schema is not bundled; the app expects three tables):
usersid,username(unique),auth(salted credential hash),full_name,sid(hashed session token)linksid,short_url(unique constraint the collision-retry logic relies on it),long_url,clicks,password(hash, nullable),monitor_key(hash),max_clicks(nullable),expires_at(nullable),user_id(nullable),created_atclicksid,link_id,ip_hash,user_agent,referer,country,time_of_click
-
Enable Google OAuth in Supabase Auth (Providers โ Google) if you want the "Sign in with Google" flow to work.
Serve the worker locally with Wrangler (this also activates the ASSETS binding and nodejs_compat):
npx wrangler devnpx wrangler deploy- Shorten a link paste a URL into the landing-page form. Toggle Advanced Options to add a custom alias, a password, a click limit, an expiration (days), or a generated QR code.
- Share copy the returned short URL (e.g.
https://s.usfahmed.dev/ab123); authenticated users see it also on their dashboard. - Protect & limit password-gated links show an unlock page; limited links stop redirecting (410) once their time or click budget is spent.
- Analyze follow the returned
/<code>/<monitor_key>link to see the analytics page, or use the dashboard's Analytics button. Everything is also exposed as JSON for API clients:GET /:code/:monitor_keyโ analytics HTML or JSON (based onAccept)GET /api/v1/my-links/:code/detailsโ aggregated stats (auth required)
- Manage delete a link anonymously via
DELETE /:code/:monitor_key, or from the dashboard as a registered user. - Generate QR codes anytime via
GET /api/v1/qr?url={url}.
Shrinko/
โ
โโโ public/ # Static assets served via the ASSETS binding
โ โโโ favicon.* / apple-touch-icon.png # Favicon set (svg/ico/png)
โ โโโ logo.png / logo.svg / og-image.png
โ โโโ robots.txt / sitemap.xml
โ โโโ (feature & UI icons: link.png, qr.png, protected.png, clicks.png, โฆ)
โ
โโโ src/
โ โโโ index.ts # Entry point loads env, registers routes
โ โโโ app.ts # Hono app + security headers + asset serving
โ โ
โ โโโ config/
โ โ โโโ env.ts # Env validation (fail-fast) + Supabase client
โ โ
โ โโโ routes/ # HTTP layer
โ โ โโโ index.ts # Route registration + 404 page
โ โ โโโ links.ts # Shorten + my-links list/details/delete APIs
โ โ โโโ redirect.ts # Redirect, password unlock, monitor, delete-by-key
โ โ โโโ auth.ts # Register / login / profile APIs
โ โ โโโ system.ts # Health checks + QR generation
โ โ โโโ pages.ts # HTML page routes (+ dashboard auth guard)
โ โ
โ โโโ pages/ # Server-rendered UI (TS-embedded HTML/JS)
โ โ โโโ landing.ts # Landing page: shorten form + advanced options
โ โ โโโ dashboard.ts # User dashboard: stats, chart, link management
โ โ โโโ monitor.ts # Per-link analytics page
โ โ โโโ auth.ts # Login / register pages (custom + Google OAuth)
โ โ โโโ profile.ts # Profile editing page
โ โ โโโ password.ts # Protected-link unlock form
โ โ โโโ generic.ts # Shared page shell (SEO meta, nav, footer)
โ โ โโโ health.ts # System status page
โ โ โโโ content.ts # API docs / about / contact / support / privacy / terms
โ โ
โ โโโ services/ # Business logic
โ โ โโโ links.ts # Link creation, click logging, monitor payloads
โ โ โโโ analytics.ts # Referrer mapping + user-agent parsing
โ โ โโโ auth.ts # Session resolution (JWT or sid cookie)
โ โ
โ โโโ middleware/
โ โ โโโ rate-limit.ts # In-memory per-IP sliding-window limiter
โ โ
โ โโโ lib/
โ โ โโโ crypto.ts # Salted SHA-256 helpers + secure random strings
โ โ โโโ validation.ts # Zod schema, reserved-alias list, regexes
โ โ โโโ html.ts # HTML escaping (XSS mitigation)
โ โ
โ โโโ types/
โ โโโ index.ts # Worker bindings type (ASSETS)
โ
โโโ .env.example # Environment template
โโโ package.json
โโโ tsconfig.json
โโโ wrangler.json # Cloudflare Workers configuration
Random 5-character codes drawn from a 62-character alphabet collide once the link count grows, and a naive insert would fail on the unique index.
The shorten route retries insertion in a loop detecting unique-violation errors from Supabase and regenerating the code, up to 1000 attempts (src/routes/links.ts). In practice this resolves collisions invisibly, and the loop exits with a friendly error if the namespace is ever exhausted.
The redirect itself has to be a fast 302, but logging a click involves a read-modify-write counter update plus an insert.
Click logging runs asynchronously after the response is prepared through executionCtx.waitUntil (with a fire-and-forget fallback when no execution context exists), and the whole routine is wrapped in try/catch a failed log entry is printed to console but never breaks the user's redirect.
Tracking visitors by raw IP would be a privacy liability and contradicts the project's privacy-first positioning.
IPs are hashed with SHA-256(SALT + ip + SALT) at the edge and only the hash is stored. "Unique clicks" are computed by counting distinct hashes so the analytics remain accurate while the raw address is never persisted or exposed.
Links created without an account still need a management mechanism, but giving them a password or login would defeat the purpose.
Each link gets a random 64-character monitor key, returned exactly once in the shorten response. Only its salted hash is stored, and it grants scoped access to that single link's analytics page and a delete endpoint. Because the stored form is a hash, a database leak does not reveal working keys.
Custom aliases short enough to serve as routes or brand names would let users squat platform names, routes, or worse.
Aliases are validated server-side with Zod against a large reserved-words list (routes like login, analytics, about, platform/brand names, and more), a profanity filter (leo-profanity), and a strict [a-zA-Z0-9]{5,15} pattern enforced on both the API and the live form on the landing page.
- Persistent, distributed rate limiting the current limiter is in-memory per worker instance; moving it to Cloudflare KV / Durable Objects (or Cloudflare's own rate-limit rules) would make quotas global.
- Stronger password hashing migrate link and account password hashes from salted SHA-256 to a dedicated KDF such as bcrypt or Argon2id.
- Link editing today links can only be deleted; editing the destination URL or limits without changing the short code is a natural next step.
- Bulk operations & exports CSV export of links and analytics, multi-select delete.
- Webhooks / developer API keys programmatic creation and monitoring without login sessions.
- Custom domains & branded short domains per user.
- Live analytics updates push click events to open monitor pages instead of requiring a reload.
- Unit & integration tests the project currently has no test suite; adding one would lock in the security and validation behavior.
- TypeScript
- Hono
- Cloudflare Workers (Wrangler,
nodejs_compat, Workers Assets)
- Supabase (PostgreSQL)
users,links,clicks
- Zod (payload schemas, alias and password rules)
- leo-profanity (alias profanity filter)
- ua-parser-js (user-agent / OS parsing)
- qr-image (server-side QR generation)
- Chart.js (client-side analytics charts)
- Supabase JS SDK (client-side Google OAuth)
Copyright ยฉ 2026 Youssef Ahmed Abdelfatah. All Rights Reserved.
This source code is provided for educational and learning purposes only.
You may view and study the code for personal educational purposes. You may not copy, reproduce, redistribute, publish, sell, use commercially, or incorporate any part of this project into another project without prior written permission from the author.
Unauthorized copying or commercial use is prohibited.
Shrinko is a personal, experimental web service. Short links are created and managed by its users, and the platform's analytics record only anonymized visit data (hashed IP addresses, country, OS, and referrer never raw IPs). Password protection, expiration, and click limits are convenience features and are not a guarantee of security or availability. Use the service responsibly and only for lawful purposes; the author assumes no liability for content shared through links created on the platform.
Youssef Ahmed Abdelfatah
๐ Portfolio
https://usfahmed.dev
๐ป GitHub
https://github.com/usfa7med
๐ผ LinkedIn
https://linkedin.com/in/usfahmed
โ๏ธ Email
hello@usfahmed.dev
