CRUMB a card from devarno-cloud

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

TypeInputHashUse case
PasswordFree-text string (min length enforced)bcryptShared secrets, team links
PIN4 or 6 digitsbcryptQuick access, low-friction
NoneDefault, 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:

FieldTypeDescription
protection_typeenumnone / password / pin
protection_hashtextbcrypt hash (never exposed in API)
protection_hinttextUser-facing hint (e.g., “My dog’s name”)
protection_max_attemptsintDefault 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:

  1. Check for existing valid session cookie
  2. If valid: proceed to redirect/proxy
  3. If invalid/missing: render HTML protection prompt (server-side)
  4. User submits password/PIN via POST /:slug
  5. Worker forwards to purr-api POST /api/links/:id/verify
  6. purr-api validates against bcrypt hash and checks attempt count
  7. On success: worker issues session cookie and redirects back to slug
  8. On failure: worker re-renders prompt with error message

Session Token Format

version.linkId.issuedAt.expiresAt.signature

All 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_attempts table:
    • 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-scheme media query
  • Input type adapts to protection type (type="password" vs type="number")
  • Hint displayed if provided
  • Error message on failure (generic “Incorrect” to prevent information leakage)

API Verification Endpoint

POST /api/links/:id/verify
Content-Type: application/json
{
"password": "user_input",
"pin": "1234"
}

Response:

  • 200 OK — correct, session token issued
  • 403 Forbidden — incorrect, attempt recorded
  • 429 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