Link Protection
smo1 intermediate 7 min read
ELI5
Sometimes you want a short link, but you do not want just anyone to use it. Link protection is like putting a padlock on a locker. You can choose a password (like a word) or a PIN (like 4 or 6 digits). When someone tries to open the link, they see a prompt instead of going straight to the destination. If they guess wrong too many times, they are temporarily locked out.
Technical Deep Dive
Source: zoomies-edge src/handlers/redirect.ts:128-148, 193-330, src/index.ts:110-170, src/lib/session-token.ts (origin/main 5bdde59); purr-api internal/service/link_service.go:472-540, internal/repository/link_repository.go:375-400, internal/handler/redirect_handler.go:231-276, internal/models/link.go:125-126, migrations/000013_protection_schema_drift.up.sql (bb6cfc2), read 2026-10-25. Corrected in v1.1: lockout window, per-link cap, PIN input type, verify status codes.
Protection Types
| Type | Input | Hash | Use case |
|---|---|---|---|
| Password | Free-text string (min length enforced) | bcrypt | Shared secrets, team links |
| PIN | 4 or 6 digits | bcrypt | Quick access, low-friction |
| None | — | — | Default, no protection |
State Machine
%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#e8f4f8', 'primaryTextColor': '#2d3748', 'primaryBorderColor': '#90cdf4', 'lineColor': '#718096', 'secondaryColor': '#f0fff4', 'tertiaryColor': '#fefcbf'}}}%%stateDiagram-v2 [*] --> Unprotected : link.protection_type = none [*] --> Protected : link.protection_type = password/pin
Protected --> Prompt : User visits slug Prompt --> Verifying : User submits password/PIN Verifying --> Redirect : Correct + within attempt limit Verifying --> Denied : Incorrect Verifying --> LockedOut : Max attempts exceeded
Denied --> Prompt : Show error, allow retry LockedOut --> Prompt : After the 15-minute window Redirect --> [*] : 302 to destination
Redirect --> SessionValid : Set session cookie SessionValid --> Redirect : Re-visit within 24h SessionValid --> Prompt : Cookie expired (>24h)Protection Data Model
Stored on the Link record:
| Field | Type | Description |
|---|---|---|
protection_type | enum | none / password / pin |
protection_hash | text | bcrypt hash (never exposed in API) |
protection_hint | text | User-facing hint (e.g., “My dog’s name”) |
protection_max_attempts | int | Default 5; enforced per IP per link |
API safety: The protection_hash field has json:"-" tag in Go, ensuring it is never serialized to JSON responses.
Edge Worker Protection Flow
When zoomies-edge resolves a protected link:
- Check for existing valid session cookie
- If valid: proceed to redirect/proxy
- If invalid/missing: render HTML protection prompt (server-side)
- User submits password/PIN via
POST /:slug - Worker forwards to purr-api
POST /api/links/:id/verify - purr-api validates against bcrypt hash and checks attempt count
- On success: worker issues session cookie and redirects back to slug
- On failure: worker re-renders prompt with error message
Session Token Format
version.linkId.issuedAt.expiresAt.signatureAll segments are base64url encoded. The signature is HMAC-SHA256(tokenBase, PROTECTION_SECRET).
Cookie attributes:
HttpOnly,Secure,SameSite=Strict- 24-hour lifetime
- Scoped to the specific link ID (cannot be reused for other slugs)
Attempt Rate Limiting
Failed attempts are tracked per IP per link:
- Per IP per link:
protection_max_attempts(default 5) failures within the last 15 minutes locks that IP out - Attempts recorded in
protection_attemptstable:link_id,ip_address,attempted_at,was_successful
- Per link across all IPs: 4 times that limit (20 by default) within 15 minutes locks everyone out, because a 4- or 6-digit PIN falls to a guesser spread over many IPs and edge-proxied verifies share Cloudflare egress IPs (
link_service.go,perLinkAttemptFactor) - The lockout lifts on its own as attempts age out of the 15-minute window; the Worker tells the visitor “try again in 15 minutes”
- Until migration 000013 (2026-10-18) production’s
protection_attempts.ip_addresswasvarchar(45), so the per-IP query errored and was skipped; only the per-link limit held. Both run now
Constant-Time Comparison
Signature verification uses constant-time comparison (crypto.timingSafeEqual in Node.js, subtle.ConstantTimeCompare in Go) to prevent timing attacks. Even if the signatures differ, the comparison takes the same amount of time, leaking no information about how many bytes matched.
HTML Prompt
The protection prompt is rendered server-side by zoomies-edge as minimal HTML:
- Dark mode support via
prefers-color-schememedia query - Input type adapts to protection type (
type="password"vstype="tel"withinputmode="numeric"for PINs) - Hint displayed if provided
- Error message on failure (generic “Incorrect” to prevent information leakage)
API Verification Endpoint
POST /api/links/:id/verifyContent-Type: application/json
{ "password": "user_input", "pin": "1234"}Response:
200 OKwith{"destination": ...}— correct; the Worker then mints the session cookie itself400 Bad Request— incorrect password or PIN, or the wrong field supplied; attempt recorded429 Too Many Requests— per-IP or per-link limit reached404/410— link not found or expired;500— protection misconfigured
Key Terms
- bcrypt → Adaptive password hashing function with built-in salt and cost factor; resistant to rainbow tables and brute force
- Session token → Short-lived HMAC-signed cookie proving the user has authenticated for a specific protected link
- Constant-time comparison → Cryptographic operation that takes the same time regardless of input match length; prevents timing attacks
- Protection hint → User-configurable reminder displayed on the prompt (e.g., “City where we met”)
- Attempt table → Audit log of failed protection guesses, enabling rate limiting and security analysis
Q&A
Q: What two protection types are supported?
A: password (4 to 128 characters) and pin (exactly 4 or 6 characters), both bcrypt-hashed.
Q: How many failed attempts are allowed per IP per link?
A: protection_max_attempts, default 5, within a rolling 15 minutes. There is also a per-link ceiling of 20 failures across all IPs in the same window.
Q: Why is the protection hash never exposed via API?
A: The field carries json:"-", and the edge-facing lookup returns an empty destination for a protected link until the secret verifies, so neither the hash nor the target URL leaves purr-api early.
Q: Why bcrypt and not Argon2?
A: bcrypt is well-supported in both Go (golang.org/x/crypto/bcrypt) and Node.js (bcrypt package), has a long security track record, and is sufficient for the threat model (online guessing, not offline hash cracking). Argon2 is preferred for new systems but bcrypt is still industry-standard.
Q: Can a user change their link’s protection after creation?
A: Yes. The link update endpoint accepts new protection_type, protection_hash, and protection_hint values. The old hash is overwritten.
Q: What happens if the protection secret is rotated? A: Existing session tokens become invalid because their HMAC signatures no longer verify. Users with valid cookies will be prompted again. This is a security feature, not a bug.
Q: Why is there no “forgot password” for protected links? A: Link protection is designed for lightweight access control, not identity management. If the owner forgets the password, they can simply remove protection via the dashboard and re-add it. There is no email recovery flow.
Examples
Think of link protection like a speakeasy:
- Password protection is knowing the secret phrase — “The moon is bright tonight” — which the bouncer whispers back to confirm
- PIN protection is a numbered keypad on the door — only 4 or 6 digits, quick to enter
- Protection hint is the clue written on the matchbook: “Think of our first date”
- Session cookie is the hand stamp that lets you leave and re-enter all night without saying the phrase again
- Attempt limiting is the bouncer who says “Three wrong passwords and I am calling the cops” — after 5 tries, you are blacklisted at this door
- Constant-time comparison is the bouncer counting the letters in your phrase at the exact same speed, whether you are close or way off, so you cannot guess based on how long he thinks
neighbors on the map
- Multi-Strategy Authentication in purr-api debugging 401 errors across different clients
- Airlock JWT Handoff & the smo1_session Cookie debugging login loops or session expiry
- Rate Limiting: Edge per-IP, Origin per-Tier debugging 429 errors for specific users