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
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 cooldown (if any) 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:
- Maximum 5 failed attempts before temporary lockout
- Attempts recorded in
protection_attemptstable:link_id,ip_address,attempted_at,was_successful
- No automatic cooldown; lockout persists until manual intervention or configurable reset
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="number") - 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 OK— correct, session token issued403 Forbidden— incorrect, attempt recorded429 Too Many Requests— max attempts exceeded
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: 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 debugging 401 errors across different clients
- Airlock JWT Handoff & Session Cookies debugging login loops or session expiry
- Tier-Based Rate Limiting debugging 429 errors for specific users