Shrinko

Security-first URL shortener running on Cloudflare Workers - custom salted-account auth alongside Google OAuth, hashed-at-rest session and monitor-key secrets, per-query ownership enforcement, privacy-preserving click analytics, and layered edge hardening. Plus edge-rendered QR codes and anonymous monitor-key link management.

SecurityTypeScriptHonoCloudflareSupabaseAnalytics
Live Demo
GitHub
Aug 16, 2026
Shrinko

Hybrid Identity: Custom Accounts and Google OAuth Behind One Resolver

THE PROBLEM

The service needs two auth paths - platform-created username/password accounts and Supabase Google OAuth - but they must produce one session carrier so that every protected route, dashboard query, and ownership check treats requests identically regardless of which provider authenticated them. Duplicating authentication per route would fork the codebase: one branch per provider, two places to forget a check, two response shapes. Worse, the raw password must never cross from the credential-verification routes into downstream business logic, and the client-side PKCE flow adds its own failure mode - Supabase JWT validation fails silently when the device clock is wrong.

WHAT I DID

Built a single resolver, resolveUserId(), that reads the Authorization header first; if it carries a Bearer token it is validated with supabase.auth.getUser() and the Supabase user id is returned, otherwise the sid cookie is hashed with SHA-256 and matched against the users.sid column. Route handlers never see which provider authenticated the request - they receive only an id or null. Registration validates every field with shared regexes (username 5-15 lowercase alphanumeric, password 8-20 with four character classes, names letters-only), checks username uniqueness before insert, and derives the credential digest from a salted composition of username and password so identical passwords never produce identical digests. The login endpoint additionally answers malformed or missing credentials only after a randomized 300-400 ms pause, so request timing cannot be used to probe the validation path. Login rotates the session token on every success and both flows set one cookie format: maxAge 3600, SameSite Lax, Secure, intentionally not HttpOnly because the client-side session-aware UI must be able to see it. The landing page also probes its own Date header with a HEAD request and warns when the device clock is off by more than 60 seconds, catching the PKCE failure mode before the user attempts sign-in.

Unified identity resolvertypescript
async function resolveUserId(c: any): Promise<string | null> {
  const authHeader = c.req.header('Authorization');
  const cookieToken = getCookie(c, 'sid');
  const token = (authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null) || cookieToken;
  if (!token) return null;
  const { data: userAuth, error } = await supabase.auth.getUser(token);
  if (!error && userAuth?.user) return userAuth.user.id;
  const tokenHash = sha256Hex(token);
  const { data: matches } = await supabase.from('users').select('id').eq('sid', tokenHash).limit(1);
  if (matches && matches.length > 0) return matches[0].id;
  return null;
}
Session issuance and rotation on logintypescript
const plainSid = generateRandomString(128);
const sidHash = sha256Hex(plainSid);

const { error: updateError } = await supabase
  .from('users')
  .update({ sid: sidHash })
  .eq('id', user.id);

setCookie(c, 'sid', plainSid, {
  path: '/',
  maxAge: 3600,
  sameSite: 'Lax',
  secure: true,
  httpOnly: false,
});
WHY THIS APPROACH

Centralizing identity behind one function means adding a third provider means extending the resolver, not rewriting middleware or re-registering protected routes. The 128-character token is generated server-side, stored only as a hash, and rotated on every login, so a stolen cookie is automatically dead as soon as the owner signs in again and at most one hour old by client expiry. Keeping the cookie JS-readable is a deliberate trade: the dashboard needs to detect auth state on page load, so HttpOnly was rejected in favor of making the token itself short-lived and revocable by rotation. The clock-skew probe exists because PKCE exchange results in a JWT whose validation is unforgiving - a wrong device clock produces a generic offline failure with no actionable error, so the client warns proactively.

THE IMPACT

One code path guards every protected route for both providers, and the raw password exists only in the request body of the two credential endpoints - every other layer consumes a resolved identity. Auto-rotating single-session tokens bound the damage of cookie theft to one hour, and the clock-skew warning removes the most confusing OAuth failure from the sign-in experience.

Opaque Secrets, Hashed at Rest

THE PROBLEM

Sessions, link-monitor keys, and passwords are all capability-bearing secrets. If they are stored in plaintext, a database read becomes instantaneous account takeover, anonymous-link hijacking, and credential recovery - and here the database client is a service-role Supabase key that bypasses Row-Level Security, so the edge runtime itself holds a path to every row. The additional requirement is that link owners need a secret to view analytics and delete their link without an account, so the capability key must be issued once and be usable forever after - plaintext storage would be the obvious but fatal way to satisfy that.

WHAT I DID

Every secret in the system follows the same pattern: generate high-entropy randomness with crypto.randomInt, hand the plaintext to the client exactly once, and persist only a digest. Session ids are 128 characters, stored as SHA-256(sid) in users.sid and rotated on each login. Monitor keys are 64 characters; createShortLink() hashes them with hashMonitorKey() before insert and returns the plaintext key in the response payload, after which only the hash exists on disk. Passwords are hashed with SHA-256 over a SALT-wrapped input via a shared formatWithSalt() primitive, and because the username is bound into the digest, two users with identical passwords still produce different stored values. The SALT comes from the environment and the application refuses to boot without it, so misconfiguration fails loudly instead of producing a working-but-unseeded hasher.

The shared secret primitivestypescript
function generateRandomString(length: number): string {
  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  let result = '';
  for (let i = 0; i < length; i++) {
    result += characters[crypto.randomInt(0, characters.length)];
  }
  return result;
}

function formatWithSalt(value: string): string {
  return `${SALT}${value}${SALT}`;
}

function sha256Hex(value: string): string {
  return crypto.createHash('sha256').update(value, 'utf-8').digest('hex');
}
Monitor key: issued once, stored hashedtypescript
const plainMonitorKey = generateRandomString(64);
const hashedMonitorKey = hashMonitorKey(plainMonitorKey);
const hashedPassword = params.password ? await hashPassword(params.password) : null;

const { data: inserted, error } = await supabase
  .from('links')
  .insert({
    long_url: params.longUrl,
    short_url: params.shortUrl,
    password: hashedPassword,
    monitor_key: hashedMonitorKey,
    max_clicks: params.maxClicks ?? null,
    expires_at: params.expiresAt ? params.expiresAt.toISOString() : null,
    user_id: params.userId ?? null,
  })
  .select();

// the plaintext monitor key is returned exactly once
insertedData.monitor_key = plainMonitorKey;
WHY THIS APPROACH

Storing digests means the database is no longer a credential store - a leak yields nothing that can authenticate. The deterministic salted digest also allows credentials to be re-derived at will: login re-computes the hash, a password change is a single UPDATE of the auth column, and the monitor-key lookup is an equality query against the stored digest. The one-time plaintext issuance keeps the capability secret on the wire for a single response, after which possession of the database is insufficient to manage the link.

ApproachWhy rejected
Store tokens/key/passwords in plaintextA single database read would immediately yield live sessions, link deletion authority, and recoverable credentials - the exact compromise the hashing exists to prevent.
Stateless signed JWTs for custom sessionsStateless tokens cannot be invalidated without a blacklist; Shrinko needs rotation-as-revocation, which only stateful digest comparison supports.
THE IMPACT

A database exfiltration no longer produces any usable credential or capability - sessions are revoked by rotation within one hour, monitor keys exist only as digests after issuance, and passwords are never recoverable from storage. The same hashing primitive additionally shapes the IP-hashing pipeline, so the entire system stores one category of value: salted SHA-256 digests.

Ownership Enforcement at the Query Layer

THE PROBLEM

The application talks to Supabase with the service-role key, which deliberately bypasses Row-Level Security: Postgres will return or mutate whatever the app asks for. That moves the entire authorization boundary into the application layer, where a single forgotten filter - a details query keyed only by short_url, a delete keyed only by the alias - becomes a broken-object-level access hole exposing every user's links and click data. On top of that, links created without an account have no user id, so anonymous ownership has to be expressed as a separate capability that must not be guessable or blindable.

WHAT I DID

Every link operation couples the resource identifier with the actor. The details endpoint filters by both short_url and user_id in one query; deletion adds a second predicate (the user id) to the delete; the dashboard listing filters clicks by the link id of links already scoped to the user; monitor-key routes filter by the SHA-256 digest of the provided key, which has 256 bits of entropy; and the dashboard analytics page resolves the user first and passes the user id as the scope. Unauthenticated access returns 401; authenticated-but-not-owner requests resolve to the same generic 404 ("Link not found or unauthorized"), so existence is not disclosed. Finally, getMonitorPayload() and the listing routes scrub sensitive columns - password and monitor_key are deleted from every serialized response and replaced at most with a boolean is_protected flag.

Identifier-only lookup is impossibletypescript
const { data: linkInfo, error: linkError } = await supabase
  .from('links')
  .select('id, created_at, long_url')
  .eq('short_url', shortUrl)
  .eq('user_id', userId)
  .single();

if (linkError || !linkInfo) {
  return c.json({ status: 'fail', reason: 'Link not found or unauthorized' }, 404);
}
Dual ownership scope: user id or capability digesttypescript
async function getMonitorPayload(shortUrl: string, hashedKey: string | null, userId: string | null) {
  let query = supabase.from('links').select('*').eq('short_url', shortUrl);
  if (hashedKey) query = query.eq('monitor_key', hashedKey);
  else if (userId) query = query.eq('user_id', userId);
  else return null;
  // password and monitor_key are stripped before serialization
}
WHY THIS APPROACH

Because the service-role client spans the redirect path, both auth providers, and anonymous creation, enabling RLS would mean maintaining a second, per-request auth context for one pool of tables and duplicating every application rule in Postgres policies. Keeping authorization in the query layer means the rule lives next to the data access it protects, in one codebase and one review path. The 404-for-non-owners semantics prevent both existence leakage and enumeration of the identifier space, at the cost of a slightly less informative error - a cost worth paying for a public shortener.

ApproachWhy rejected
Enable Row-Level Security with per-user JWTsWould require a second authenticated Supabase client for every request and mirror all ownership rules in Postgres policies - splitting the authorization decision between two engines.
Fetch all rows and filter in the clientDelegates authorization to the request origin: every user's links, passwords hashes visibility aside, and click data would traverse the network before being hidden in the UI.
THE IMPACT

There is no single-identifier read anywhere in the system: the authenticated actor id or a 256-bit capability digest is a mandatory component of every link query, so identifier-only requests cannot escalate into cross-user access. Sensitive fields never reach the serialized boundary, and ownership failures are indistinguishable from missing resources to clients.

Capability Keys: Managing Links With No Account At All

THE PROBLEM

Shortening works without an account, so an anonymous link has no user_id to scope ownership to - yet its creator still needs the two management powers: reading analytics and deleting the link. The easy answers are fatal: storing the management secret in plaintext makes a database read equivalent to taking over the link, and a per-account wall destroys the anonymous flow that is the product's primary path. What is needed is a bearer capability - a secret the creator possesses, that the database does not, granting exactly two powers over exactly one link, forever.

WHAT I DID

createShortLink() derives a 64-character monitor key from the same crypto.randomInt primitive used for sessions, persists only its SHA-256 digest in links.monitor_key, and returns the plaintext exactly once - inside the redirect_url of the shorten response (/{short_url}/{monitor_key}). Anonymous clients keep the key in localStorage through the "Recently Shortened" panel, where every entry renders an Analytics link that carries it. GET /:short_url/:monitor_key hashes the presented key and scopes the entire payload query to that digest; the route content-negotiates on Accept, serving browsers a full server-rendered analytics page - link card, total and unique clicks with today-vs-yesterday deltas, expiry and click-limit status, a Chart.js clicks-over-time chart, and a per-click table of time, country, OS, and referrer domain - and API clients the same data as JSON. DELETE /api/v1/:short_url/:monitor_key couples short_url with the key digest in a single delete. Logged-in owners reach the same page through /dashboard/analytics/:short_url, scoped by user_id instead; monitor-route scope failures return the same "Link not found or invalid monitor key" 404, and the dashboard analytics page answers a miss with its own 404 page - either way a non-owner never learns whether the link exists. Password and monitor_key columns are scrubbed from every payload and replaced by an is_protected boolean, and the timestamps embedded into the chart script are JSON-escaped with < rendered as \u003c so stored data can never break out of the inline script.

One endpoint, two content typestypescript
app.get('/:short_url/:monitor_key', rateLimitMiddleware(), async (c) => {
  const shortUrl = c.req.param('short_url');
  const hashedKey = hashMonitorKey(c.req.param('monitor_key'));
  const payload = await getMonitorPayload(shortUrl, hashedKey, null);

  const accept = c.req.header('accept') || '';
  if (!accept.includes('text/html')) {
    if (!payload) return c.json({ detail: 'Link not found or invalid monitor key' }, 404);
    return c.json(payload);
  }
  if (!payload) return c.html(getMonitorPageHtml(null, shortUrl), 404);
  return c.html(getMonitorPageHtml(payload, shortUrl));
});
Anonymous deletion scoped by key digesttypescript
const { data, error } = await supabase
  .from('links')
  .delete()
  .eq('short_url', shortUrl)
  .eq('monitor_key', hashMonitorKey(monitorKey))
  .select();

if (error) throw error;
if (!data || data.length === 0) {
  return c.json({ detail: 'Link not found or invalid monitor key' }, 404);
}
WHY THIS APPROACH

The capability design inverts the database's value: because the plaintext key is issued once and never stored, possession of the database - even the service-role path itself - is insufficient to manage a link, and a leaked key is a capability rather than a credential: it cannot authenticate anywhere and its dual-predicate queries stop it from working on any other link. Content negotiation gives one endpoint to humans and machines without maintaining two management APIs, and routing logged-in owners through the same page keeps the anonymous and authenticated management surfaces from drifting apart.

ApproachWhy rejected
Store the plaintext key in the database for later lookupA database read would immediately yield working management capability; one-time issuance is what makes the leak worthless.
Force an account to manage or delete linksDestroys the anonymous shortening flow; the capability key preserves it without weakening ownership.
THE IMPACT

Anonymous creators get real ownership with no account: two powers, one link, one key, issued once and unrecoverable from storage. Capability leakage is bounded to a single link, humans and machines read the same data through one endpoint, and the 404-on-scope-failure semantics keep non-owners from even learning whether the link exists.

Windowed Rate Limiting with Auth-Differentiated Quotas

THE PROBLEM

The public surface of a shortener is a brute-force target: login and registration are guessable-credential endpoints, the shorten endpoint can be hammered into a link-spam factory, and QR generation can be driven as an image-request amplifier. But the mitigation has to survive the architecture: this runs on Workers, where memory is per-isolate, and the response must stay sub-millisecond on the redirect path. It also has to be sane for real users - an uptime monitor polling health never deserves a 429, and a rate-limited visitor hitting a password-protected link should still receive a coherent unlock page, not a bare JSON body.

WHAT I DID

A fixed-window limiter keeps a Map of (method, path, client IP) buckets reset every 60 seconds, with the client IP taken from the first entry of x-forwarded-for before falling back to x-real-ip. The middleware accepts a per-route ceiling: login/register run at the 10/min default, QR generation at 10/min, profile reads at 120/min, and the shorten endpoint differentiates by auth state - 10/min anonymous, 20/min when a sid cookie or Bearer token is present, doubling the quota with a message that explicitly markets registration. When a bucket is exhausted the middleware negotiates the response by request shape: a POST to a single-segment path re-renders the password-unlock form with the 429 embedded as a warning, HTML GETs outside /api get a branded "Rate Limit Exceeded" page, and API clients receive structured { status, reason } JSON. The health endpoints are deliberately registered without the middleware so monitoring probes are never throttled.

Fixed-window buckettypescript
type Bucket = { count: number; windowStart: number };
const rateBuckets = new Map<string, Bucket>();
const WINDOW_MS = 60_000;

function rateLimit(key: string, maxRequests: number): boolean {
  const now = Date.now();
  const bucket = rateBuckets.get(key);
  if (!bucket || now - bucket.windowStart >= WINDOW_MS) {
    rateBuckets.set(key, { count: 1, windowStart: now });
    return true;
  }
  if (bucket.count >= maxRequests) return false;
  bucket.count += 1;
  return true;
}
Auth-aware quotas and shape-aware 429stypescript
const isShorten = c.req.path === '/api/v1/shorten' || c.req.path === '/api/shorten';
const maxReqs = customMax || (isShorten ? (isAuthenticated ? 20 : 10) : 10);

const ip = getClientIp(c);
const routeKey = `${c.req.method}:${c.req.path}:${ip}`;
if (!rateLimit(routeKey, maxReqs)) {
  const parts = c.req.path.split('/').filter(Boolean);
  if (c.req.method === 'POST' && parts.length === 1 && parts[0] !== 'shorten') {
    const shortUrl = parts[0];
    return c.html(getPasswordFormHtml(shortUrl, 'Rate limit exceeded. Please wait a minute.', 'warning'), 429);
  }
const accept = c.req.header('accept') || '';
      if (c.req.method === 'GET' && accept.includes('text/html') && !c.req.path.startsWith('/api')) {
        const message = isShorten
          ? `You have exceeded the anonymous rate limit (${limitStr}). Register for a free account to double your quota!`
          : `You have exceeded the rate limit (${limitStr}). Please wait a moment and try again.`;
        return c.html(getGenericPageHtml('Rate Limit Exceeded', `<div style="text-align: center; padding: 50px 20px;"><h2 style="color: #f59e0b;">Too Many Requests</h2><p>${message}</p></div>`), 429);
      }
      return c.json({ status: 'fail', reason }, 429);
WHY THIS APPROACH

An in-memory fixed window costs zero external I/O on the hottest path in the system - the redirect - whereas a shared KV or Durable Object counter would add a storage round-trip to every single hit, including 302 responses that should cost nothing. Per-route ceilings and auth-aware quotas make the limiter a business policy, not just a flood valve: it caps credential guessing while doubling the shorten budget for signed-in users, and it keeps the QR endpoint at a cost level that cannot be abused as a request amplifier. The honest trade is that memory-local buckets are per-isolate, so this layer is defense-in-depth rather than a global cap - it is fast, free, and always on, and the edge platform's own protections remain available above it.

ApproachWhy rejected
External rate-limit service or WAF rulesRuns outside the application, so it cannot render application-aware 429 pages (the password form, the quota-upsell copy) and adds configuration and cost for behavior that belongs in the request lifecycle.
Shared KV / Durable Object sliding countersEvery permitted request pays for a storage round-trip; the in-memory window keeps the redirect hot path entirely free of external I/O.
THE IMPACT

Credential-guessing and link-spam are bounded to a fixed number of attempts per 60-second window per client, and every exhausted request receives a response shaped for the client that made it - a usable unlock form instead of a dead JSON 429 in the password flow. Health monitoring is unaffected, and the redirect path pays no measurable cost for the protection.

The Shorten Contract as a Single Validation Schema

THE PROBLEM

The shorten payload is a small state machine, not a flat form: type is random or alias; alias exists only for alias mode but then must be 5-15 alphanumeric, must not collide with roughly 430 reserved words covering routes, platform paths, and brand names, and must pass a profanity dictionary; isLimited forces the choice of a limitation type, time-limited links demand expires_in, click-limited links demand max_clicks; isProtected demands a password meeting four character-class rules. Validation bugs here poison the entire namespace - an alias squatting a reserved route breaks the site itself - and a payload-shaped hole lets clients inject fields the business logic never intended to accept.

WHAT I DID

The entire contract lives in one zod schema: a base object of typed fields (url, type, isLimited, isProtected, plus optionals) refined by a superRefine pass that encodes the cross-field invariants - alias requiredness and format, the reserved-word and profanity checks, the limitation_type/expires_in/max_clicks dependency chain, and the full password-complexity rule set. Zod strips unknown keys by default, so extra client fields never reach the handler, and the first validation issue is extracted and returned to the client as a single human-readable detail string. Alias creation additionally pre-checks the database for a duplicate and maps unique-constraint violations from the insert into the same "alias already taken" response; random mode retries generation against the unique constraint up to 1,000 attempts, so a 5-character code drawn from a 62-character alphabet self-heals through collisions instead of failing a request.

Cross-field invariants in one schematypescript
const ShortenRequestSchema = z
  .object({
    url: z.string().url(),
    type: z.enum(['random', 'alias']),
    alias: z.string().optional(),
    isLimited: z.boolean(),
    limitation_type: z.enum(['time', 'clicks', 'both']).optional(),
    expires_in: z.number().positive().optional(),
    max_clicks: z.number().positive().optional(),
    isProtected: z.boolean(),
    password: z.string().optional(),
  })
  .superRefine((val, ctx) => {
    if (val.type === 'alias') {
      if (!val.alias) {
        ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Alias is required when type is 'alias'" });
      } else {
        if (!/^[a-zA-Z0-9]{5,15}$/.test(val.alias)) {
          ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Alias must be between 5 and 15 alphanumeric characters' });
        }
        if (RESERVED_PATTERN.test(val.alias)) {
          ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'This alias is reserved' });
        }
        if (leoProfanity.check(val.alias)) {
          ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Profanity detected' });
        }
      }
    }
    if (val.isLimited && !val.limitation_type) {
      ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'limitation_type is required when isLimited is True' });
    }
    // ...expires_in / max_clicks / password-complexity branches
  });
Collision-retry code generationtypescript
} else if (payload.type === 'random') {
  const maxAttempts = 1000;
  let attempts = 0;
  while (attempts < maxAttempts) {
    const shortUrl = generateRandomString(5);
    try {
      result = await createShortLink({
        longUrl: payload.url,
        shortUrl,
        expiresAt,
        password: payload.isProtected ? (payload.password as string) : null,
        maxClicks,
        userId,
      });
      break;
    } catch (e) {
      if (isUniqueViolation(e)) {
        attempts += 1;
        continue;
      }
      throw e;
    }
  }
}
WHY THIS APPROACH

One schema beats scattered if-chains because the conditional dependencies are explicit and tested as a unit - the alternative of two schemas (alias vs random) would duplicate the isLimited/isProtected branches and drift apart during maintenance. The retry loop exists because the unique key is DB-enforced: the check-then-insert race between two simultaneous requests with the same random code is resolved by the constraint itself, and the loop turns that race into a retry instead of a 500. Putting the reserved-word list into a single compiled regex keeps the namespace policy enforceable at the same place where aliases are accepted, so a route added later propagates into the filter by list edit alone.

THE IMPACT

Every invalid payload is rejected before the database and before any business logic runs: impossible limitation combinations, weak link passwords, reservation-breaking and profane aliases never reach an insert. Random-code collisions become a bounded, silent retry, and the client-facing error vocabulary stays consistent because every rejection flows through the same validation-failure serializer.

Privacy-Preserving Click Analytics at the Edge

THE PROBLEM

A URL shortener is the canonical surveillance instrument: every redirect passes through the service, and a naive implementation would store raw visitor IPs, full user agents, and exact referrers - a perfect re-identification kit for the people clicking shortened links. The second constraint is performance: analytics capture sits on the single most latency-sensitive path in the system, the 302 itself, and an awaited database write per click would tax every redirect. The analytics must still answer real questions: how many distinct visitors, from which countries, through which referrers, on which platforms.

WHAT I DID

The pipeline minimizes data before it touches storage. At redirect time the client IP is immediately hashed with hashIp() - SHA-256 over a SALT-wrapped value - so no raw address is ever written; the user-agent string is run through UAParser and reduced to its OS name only, discarding the raw string; the referer is matched against a curated table of known sources (search engines, social networks, LLM platforms, chat apps) and falls back to the bare domain; and the country comes from Cloudflare's cf-ipcountry edge header, which clients cannot spoof. Telemetry is then fired with executionCtx.waitUntil(), so the Worker returns the 302 immediately and keeps the isolate alive only long enough to finish the asynchronous write; the write itself is wrapped in a try/catch so a failing insert can never break a redirect. Unique-click counts are computed in the application layer as the size of a Set of distinct ip_hash values per link, and the details endpoint aggregates referrers, countries, browsers, OS, and a per-day timeline from the stored click rows.

Telemetry fired after the redirect is preparedtypescript
const clientIp = getClientIp(c);
const hashedIp = hashIp(clientIp);

const rawReferer = c.req.header('referer');
const cleanReferer = getReferrerSource(rawReferer);

const rawUa = c.req.header('user-agent') || '';
const parsedUa = parseUserAgent(rawUa);

const country = c.req.header('cf-ipcountry') || 'unknown';

const clickPromise = logClickAndIncrement(shortUrl, linkData.id, hashedIp, parsedUa, cleanReferer, country);
if ((c as any).executionCtx) {
  (c as any).executionCtx.waitUntil(clickPromise);
} else {
  void clickPromise;
}

return c.redirect(linkData.long_url, 302);
The raw user agent never reaches storagetypescript
function parseUserAgent(userAgent: string | null | undefined): string {
  if (!userAgent) return 'unknown';
  const parser = new UAParser(userAgent);
  const os = parser.getOS();
  if (os.name) return os.name.toLowerCase();
  return 'unknown';
}

async function logClickAndIncrement(shortUrl, linkId, ipHash, userAgent, referer, country) {
  const { data: current } = await supabase.from('links').select('clicks').eq('short_url', shortUrl).single();
  const newCount = (current?.clicks || 0) + 1;
  await supabase.from('links').update({ clicks: newCount }).eq('short_url', shortUrl);
  await supabase.from('clicks').insert({ link_id: linkId, ip_hash: ipHash, user_agent: userAgent, referer, country });
}
WHY THIS APPROACH

Hashing the IP before storage means the database contains no personally-recoverable address at any point in the pipeline - uniqueness is preserved as a first-class metric without the data that would make it a tracking device. Reducing the user agent to a parsed OS name gives platform breakdowns while eliminating device fingerprinting. The fire-and-forget logging pattern is the only way to keep telemetry on a redirect path: the client sees the 302 as soon as the counters are read, and the write completes in the background or is dropped on failure. Alternatives like pushing clicks through a queue or a second worker add infrastructure and ordering complexity to a service whose entire value is a fast redirect.

THE IMPACT

The database contains no raw IP addresses, no raw user agents, and no un-normalized referrers - visitor identity cannot be reconstructed from stored analytics, and this is enforced by construction because the raw values never reach the write path. Redirects gain analytics at zero added client-visible latency, with a unique-visitor estimate computed from hashed values that remain functionally consistent per link.

The Analytics Read Path: Unique Visitors Computed From Hashes

THE PROBLEM

The write path minimizes data before storage, but the product has to present that data: per-link detail pages, dashboard aggregates, and breakouts by referrer, country, browser, and OS - all derived from rows in which the IP exists only as a salted hash and the user agent only as an OS name. The distinct-visitor metric has to survive the minimization: with raw IPs gone, "unique visitors" must be computed from the hashes themselves, and the same counting semantics must hold everywhere the number appears - the details API, the dashboard, and the monitor page - or the product starts reporting three different truths.

WHAT I DID

The details endpoint loads the link with a dual scope (short_url plus user_id) and aggregates its click rows in the application layer: total clicks is the row count, unique clicks is the size of a Set over the ip_hash values, referrers are grouped by extractDomain(), countries by the stored value, browsers and OS by re-parsing the stored user-agent column, and a per-day timeline is built by keying ISO dates. /api/v1/my-links applies the same Set computation per link so the dashboard can render each card with its unique count, then the dashboard client computes today-vs-yesterday deltas for clicks, unique clicks, and links created, tallies protected, limited, and expired links, draws a Chart.js area chart of clicks per day, and supports sorting (newest, oldest, most and least clicked, expired) with five-link pagination. The monitor page renders the same aggregations server-side, sharing the identical Set-based counting logic.

Uniqueness as a Set of hashestypescript
const uniqueIps = new Set<string>();
for (const click of clicksData) {
  if (click.ip_hash) uniqueIps.add(click.ip_hash);
}
linkData.unique_clicks = uniqueIps.size;
Read-path aggregation over minimized rowstypescript
clicks.forEach(click => {
  const ref = extractDomain(click.referer);
  referrers[ref] = (referrers[ref] || 0) + 1;

  const country = click.country || 'Unknown';
  countries[country] = (countries[country] || 0) + 1;

  if (click.time_of_click) {
    const date = new Date(click.time_of_click).toISOString().split('T')[0];
    timeline[date] = (timeline[date] || 0) + 1;
  }
});
WHY THIS APPROACH

Computing uniqueness as the size of a Set over salted hashes is the only faithful way to estimate distinct visitors once raw IPs are gone, and it keeps the privacy property intact end-to-end: the value that measures a visitor is the same value that cannot identify them. Keeping aggregation in application code instead of SQL window functions means one implementation serves the API, the dashboard, and the monitor page - a SQL rewrite would fork the counting semantics across three surfaces and they would silently drift apart.

ApproachWhy rejected
COUNT(DISTINCT ip_hash) in SQLCorrect in isolation, but it forks the counting logic away from the application layer; one Set implementation shared by all three surfaces is the only way the numbers stay identical everywhere.
Pre-aggregate counters at write timeAdds aggregation work and stored state to the redirect hot path, and loses the raw detail rows the history table and breakouts depend on.
THE IMPACT

Unique-visitor counts survive IP hashing because they are computed from the hashes themselves; every breakout and trend is derived from already-minimized values; and total, unique, and delta numbers are identical whether they come from the API, the dashboard, or the monitor page.

One Global Middleware for Response Hardening

THE PROBLEM

The application is server-rendered HTML with inline scripts, pulls Chart.js and the Supabase browser SDK from a CDN, connects to Supabase and Google Fonts from different origins, and embeds visitor-derived values in pages. Default behavior would ship pages with mixed-content risk, MIME-sniffing ambiguity, no clickjacking protection, and permissive referrer leakage - and the fix has to survive a codebase where a new page handler is added every few weeks without remembering to harden its own headers.

WHAT I DID

A single app.use("*") middleware runs after every handler and stamps headers onto every response the Worker emits. The Content-Security-Policy explicitly allows only what the app actually consumes: "self", inline scripts and styles (the templates are inline by design), scripts from cdn.jsdelivr.net, Google Fonts and Font Awesome style/font origins, https: images, and connect-src limited to "self" and https://*.supabase.co - while blocking object-src, constraining base-uri, forbidding frame ancestors, and limiting form-action to "self", which pins the password-unlock POST to the same origin. Strict-Transport-Security is set with includeSubDomains and preload, X-Content-Type-Options: nosniff and X-Frame-Options: DENY are set alongside the CSP frame-ancestors block, Referrer-Policy is strict-origin-when-cross-origin, and Permissions-Policy strips camera, microphone, geolocation, payment, usb, and accelerometer access. Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Resource-Policy: same-origin, and Origin-Agent-Cluster round out the isolation set.

Response hardening middlewaretypescript
app.use('*', async (c, next) => {
  await next();
  c.res.headers.set('Content-Security-Policy',
    "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " +
    "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
    "font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; " +
    "connect-src 'self' https://*.supabase.co; object-src 'none'; base-uri 'self'; " +
    "frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests");
  c.res.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
  c.res.headers.set('X-Content-Type-Options', 'nosniff');
  c.res.headers.set('X-Frame-Options', 'DENY');
  c.res.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  c.res.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=()');
  c.res.headers.set('Cross-Origin-Opener-Policy', 'same-origin');
  c.res.headers.set('Cross-Origin-Resource-Policy', 'same-origin');
  c.res.headers.set('Origin-Agent-Cluster', '?1');
});
WHY THIS APPROACH

Hardening in one middleware means a new route cannot forget its headers - the policy is structural, applied after the response exists, so even an error response from the notFound handler gets the same treatment. The CSP is written as an explicit allowlist of what the current build actually loads, which is what makes it enforceable: the CDN entries exist only because Chart.js and the Supabase SDK are loaded from jsdelivr, and form-action: "self" exists because the password-gate form is the only server-rendered form target in the system. The double clickjacking defense (CSP frame-ancestors plus X-Frame-Options) covers both CSP-aware and legacy user agents, and upgrade-insecure-requests converts any accidentally http-served asset into https before it reaches the network.

THE IMPACT

Every response - pages, JSON, redirects, even 404s - carries the full hardening set, so clickjacking is blocked by two mechanisms, MIME sniffing is disabled, cross-origin referrer leakage is bounded to the origin, mixed content is upgraded, and permission-gated device APIs are unavailable to page scripts. A single line in the app bootstrap governs all of it.

The Redirect Gate: Expiry, Click Caps, and Password Locks

THE PROBLEM

A shortened URL is a fully public endpoint: anyone can hit it, and it must enforce link-level policy on every single hit - expiration dates, click quotas, and protection passwords - before disclosing the destination or recording telemetry. A naive redirect would reveal the long URL before checking anything, count failed unlock attempts as legitimate clicks, and allow refresh-safety bugs that double-count or replay submissions through the password form.

WHAT I DID

GET /:short_url runs a fixed order of gates. It resolves the link record, returns the generic 404 page when the alias does not exist, checks expires_at against the current time and the stored click counter against max_clicks - an exhausted link gets a 410 Gone page, not a redirect - and only then checks whether a password is set; if it is, the response is the unlock form, and the long URL is never part of that page. Only when every gate passes does the route derive the analytics fields and fire the mid-flight telemetry, then answer 302 to the destination. The unlock path mirrors the same gates: POST /api/v1/:short_url parses the form, re-checks expiry and quota, verifies the password against the stored digest, rejects failures with a 403 re-rendered form, and on success logs the click and answers 303 See Other - the post-redirect-get status - so refreshing the browser window replays a GET to the destination instead of re-submitting the form and double-counting. Failed password attempts return 403 without recording a click, and both routes share the rate limiter, whose 429 response is itself the unlock form with an embedded warning.

Gates run before the redirecttypescript
if (linkData.expires_at) {
  const expirationDate = new Date(linkData.expires_at);
  if (new Date() > expirationDate) isExpired = true;
}

if (linkData.max_clicks != null) {
  const currentClicks = linkData.clicks || 0;
  if (currentClicks >= linkData.max_clicks) isExpired = true;
}

if (isExpired) {
  return c.html(getGenericPageHtml('Link Not Available', /* ... */), 410);
}

if (linkData.password) {
  return c.html(getPasswordFormHtml(shortUrl));
}
Unlock verifies the digest, then answers 303typescript
const passwordMatches = await verifyPassword(password, linkData.password);
if (!passwordMatches) {
  return c.html(getPasswordFormHtml(shortUrl, 'Incorrect password. Please try again.'), 403);
}

// log the click fire-and-forget, then post-redirect-get
return c.redirect(linkData.long_url, 303);
WHY THIS APPROACH

Ordering the gates before any redirect guarantees that expired or quota-exhausted links never leak access at all, and that protected destinations are never disclosed to unauthenticated clients. The 303 after a successful unlock is the HTTP-correct answer for "a form POST produced a resource": the follow-up GET is what a refresh re-executes, so accidental resubmission cannot inflate click counts or log phantom visits. Keeping one code path (and the same gate helpers) between the GET and POST branches prevents the two flows from drifting apart in policy behavior - both refuse what the other refuses.

THE IMPACT

Link availability is enforced entirely on the public path: expired and click-exhausted links return 410, protected links can only be unlocked against the stored digest with the destination withheld, failed attempts are not counted as clicks, and refresh-safe redirects prevent phantom double-counting. One rate limiter covers both gates, so brute-forcing a link password is bounded exactly like any other route.

Edge-Rendered QR Codes, Rate-Capped and Immutably Cached

THE PROBLEM

Every shortened link deserves a scannable, downloadable QR code, generated on the landing page at creation time and on demand from the dashboard. But the endpoint is a natural abuse vector - QR rendering is synchronous CPU work an attacker can drive as an image-request amplifier - and the alternative of a third-party QR API would ship every encoded URL to an external service, exactly the data-sharing the privacy pipeline exists to prevent.

WHAT I DID

GET /api/v1/qr?url=... renders the PNG synchronously at the edge with qr-image (size 10, margin 2, parse_url enabled) - no external QR service, no client-side library. The route sits behind the same fixed-window limiter with a dedicated ceiling of 10 requests per minute per client, returns 400 for a missing url parameter, and answers render failures with a sanitized 500 that leaks no internals. Every successful image is stamped Cache-Control: public, max-age=31536000, immutable: because the encoded URL is part of the request, the URL is the cache key, so repeat scans are served from cache at zero worker cost. The landing page fetches the endpoint into an <img> right after shortening with a download link, and the dashboard opens the same endpoint in a per-link modal.

The whole endpointtypescript
app.get('/api/v1/qr', rateLimitMiddleware(10), async (c) => {
  const fullUrl = c.req.query('url');
  if (!fullUrl) return c.json({ status: 'fail', detail: 'Missing url parameter.' }, 400);
  try {
    const qrBuf = qrImage.imageSync(fullUrl, { type: 'png', size: 10, margin: 2, parse_url: true });
    return c.body(new Uint8Array(qrBuf), 200, {
      'Content-Type': 'image/png',
      'Cache-Control': 'public, max-age=31536000, immutable',
    });
  } catch (e: unknown) {
    console.error('[QR]', (e as Error | null)?.message || e);
    return c.json({ status: 'fail', detail: 'Failed to generate QR code.' }, 500);
  }
});
WHY THIS APPROACH

Rendering at the edge keeps QR generation inside the request lifecycle with zero external dependencies, and keeps encoded URLs inside the first-party boundary - the privacy guarantee holds because no visitor URL ever leaves the Worker. The fixed 10/min ceiling makes the endpoint unprofitable as a request amplifier, and the immutable cache turns the most common cost - identical repeated scans - into a permanent cache hit.

ApproachWhy rejected
Third-party QR APIShips every encoded URL to an external provider and adds a network round-trip; the same reasoning that hashes IPs before storage argues against sending URLs out at all.
Client-side QR libraryAdds a heavy dependency and per-page compute when the same image can be rendered once and served from cache forever.
THE IMPACT

Every link gets an offline shareable form at no external round-trip, identical QR requests cost nothing after the first, and the per-client cap keeps the endpoint from becoming a CPU-amplification tool.

Profile Management and the Session Lifecycle

THE PROBLEM

The registration surface was hardened at creation time, but the same fields remain editable: full name, username, and password. Each edit re-opens the validation surface, and the username change carries a subtle hazard - the credential digest is deterministically bound to the username, so renaming without re-deriving the digest would silently lock the user out at the next login. Separately, sessions die all the time - rotation, expiry, deletion - and a half-logged-in client left behind is a ghost identity: stale Supabase tokens and orphaned localStorage that keeps greeting a user who no longer exists.

WHAT I DID

POST /api/v1/profile applies the exact registration regexes to every provided field, and username changes run a uniqueness query that excludes the current user id - renaming to your own username is a no-op, colliding with another user is a 409. A password change re-derives the digest as formatWithSalt(newUsername) concatenated with formatWithSalt(newPassword) - the same derivation login uses - so the stored auth stays coherent after the identity component of the digest changes; the full name is re-capitalized and updates merge into a single UPDATE scoped by user id. GET /api/v1/profile resolves either provider and returns provider-appropriate fields (Google email, picture, and name; custom username and full name). On the client, every page that displays auth state probes /api/v1/profile on load, and a 404 - a user who no longer exists - triggers a full teardown: Supabase signOut, the sid cookie expired, localStorage wiped except the consent flag and the link-history panel, then a redirect home.

Rename re-binds the digesttypescript
if (username !== undefined) {
  if (!USERNAME_REGEX.test(username)) return c.json({ status: 'fail', detail: '...' }, 400);
  const { data: dup } = await supabase.from('users').select('id').eq('username', username).limit(1);
  if (dup && dup.length > 0 && dup[0].id !== userId) return c.json({ status: 'fail', detail: 'Username already taken.' }, 409);
  updates.username = username;
}

if (new_password !== undefined) {
  if (!PASSWORD_REGEX.test(new_password)) return c.json({ status: 'fail', detail: '...' }, 400);
  updates.auth = sha256Hex(`${formatWithSalt(username || user.username)}${formatWithSalt(new_password)}`);
}
Dead-session teardown on loadtypescript
const res = await fetch('/api/v1/profile');
if (res.status === 404) {
  await supabaseClient.auth.signOut();
  document.cookie = 'sid=; path=/; max-age=0; SameSite=Lax';
  const keep = ['cookie_consent', 'linkShrinkerHistory'];
  Object.keys(localStorage).forEach(k => {
    if (keep.indexOf(k) === -1) localStorage.removeItem(k);
  });
  window.location.href = '/';
}
WHY THIS APPROACH

Reusing the registration regexes means the update path can never relax a rule the create path enforces - one vocabulary of credential rules across the whole identity surface. Re-deriving the digest on rename is not defensive extra credit, it is what keeps the deterministic hash scheme consistent after its username component changes. The load-time probe turns an invisible dead session into an explicit clean sign-out, and it is precisely why the cookie can afford to be JS-readable: the client is trusted to observe the source of truth and clean up after itself when the session is gone.

ApproachWhy rejected
Track session expiry with client-side timersTimers cannot see server-side invalidation - a rotated or deleted session still renders as logged in; probing the profile endpoint on load observes the actual auth state.
Leave stale localStorage profile data behindProduces a ghost identity: the nav keeps greeting a user whose session is dead, and stale tokens linger; the wipe guarantees UI state matches server truth.
THE IMPACT

Profile edits can never weaken the credential rules or desynchronize the username-bound digest, username collisions are caught before any write, and every stale session self-heals on the next page load - sign-out, cookie clear, storage wipe - so the client's identity view is always reconciled with the server's.

Key Technical Achievements

Unified Identity Resolver

Custom username/password accounts and PKCE Google OAuth resolve through one server-side function, so the raw password never reaches downstream logic and protected routes never know which provider authenticated the request.

Hashed-at-Rest Secrets

Sessions, monitor keys, and passwords exist in the database only as salted SHA-256 digests; plaintext is issued exactly once and session rotation acts as revocation, so a database leak yields no usable credential or capability.

Query-Layer Ownership Enforcement

Against a service-role Supabase client that bypasses RLS, every link query couples the identifier with the authenticated actor id or a 256-bit capability digest, and non-owner requests resolve to the same 404.

Auth-Differentiated Rate Limiting

A 60-second fixed-window limiter per route and client bounds credential guessing and link spam, doubles the shorten quota for signed-in users, and renders shape-aware 429 pages that keep the password flow usable.

Privacy-First Click Pipeline

Visitor IPs are salted-hashed before storage, raw user agents are reduced to a parsed OS name, referrers are normalized to known sources, and telemetry is fired through waitUntil so redirects never wait on writes.

Single-Schema Shorten Contract

One zod schema encodes the alias/limitation/protection state machine with reserved-word, profanity, and cross-field rules, while random-code collisions self-heal through a bounded unique-constraint retry loop.

Uniform Response Hardening

One global middleware stamps every Worker response with a CDN-scoped CSP, HSTS preload, dual clickjacking blockers, MIME-sniffing protection, and restricted permission and referrer policies.

Capability-Based Anonymous Management

A 64-character monitor key, issued exactly once and stored only as a salted digest, grants analytics and deletion for a single link without an account - a leaked key is a bounded capability, never a credential.

Edge QR Generation

QR codes render synchronously on the Worker with an immutable one-year cache and a 10/min per-client cap, so encoded URLs never leave the first-party boundary and the endpoint cannot be driven as an image amplifier.

Read-Path Analytics

Unique visitors are counted as the size of a Set over salted IP hashes, and per-link breakouts, day-over-day deltas, and dashboard trends are all derived from already-minimized data.