Skip to content

Build1 publisher3 min readPublished

Upwork Scout signs its 15-minute login link and year-long unsubscribe token with one secret

The whole auth layer of this job-alert tool is about fifty lines. Three credentials with lifetimes of 15 minutes, 30 days and a year come out of one HS256 secret, separated by a custom claim that one function compares.

The Engineer · Build desk

Illustration accompanying Upwork Scout signs its 15-minute login link and year-long unsubscribe token with one secret

What happened

  • Upwork Scout, a job alert tool at upwork-scout.com, has no password field and never had one, and its author says the work he thought he had removed moved into a single auth file.
  • Three credentials, a login link, a session cookie and an unsubscribe link, are all HS256-signed JWTs issued from the same secret, carrying two custom claims: a uid and a purpose word.
  • Verification takes the expected purpose as an argument and returns null when the token's purpose claim does not match it or when the uid claim is not a string.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • exposure Normal operation of the product mails thousands of long-lived, uid-bearing credentials into inboxes the developer does not control, and one comparison in one function is what stops the session reader from honouring them.
  • decision The longest-lived credential in the system got its lifetime from a deliverability argument: an expired unsubscribe link produces spam reports, and spam reports cost the whole list.
  • constraint Because separation lives in a payload claim instead of the key material, every call site has to know which purpose it is verifying for, and the cryptography cannot enforce the answer.
  • cost A test suite that only exercises login and unsubscribe buys no protection here, so the team pays for a negative test that hands an unsub token to the session reader and expects a refusal.

Call `verifyToken(token, "session")` and two things happen in order. The signature is checked against the single secret, then the function compares the token's `purpose` claim to the string you passed, returning `null` on mismatch and the uid otherwise [7]. The signature check cannot tell the three credentials apart, because all three were signed with that same secret [5].

The author's own test, described in a post on dev.to, is to delete that comparison. The build stays green, login works, unsubscribe works [8]. Every alert email ever sent carries a valid year-long token with a uid in its query string, and the session reader would then accept one [9]. "The only reason a footer link is not a login is one string comparison," he wrote [10]. "A password-based app has no sentence like that in it anywhere" [11].

The lifetimes as coded are `15m` for the magic link, `30d` for the session cookie and `365d` for the unsubscribe link [12]. A year is 525,600 minutes, so the credential mailed to every subscriber in every alert outlives the login credential by a factor of about 35,000 [20]. The 15 minutes is not chosen against an attacker; it is for inboxes that get shared, forwarded, synced to an old laptop and left open, and the login link is the one credential that travels through infrastructure the author does not control [13]. The year is a deliverability decision: a recipient who clicks a dead unsubscribe link sees "this link is invalid or expired" and reports the message as spam instead [15]. The unsubscribe token gets the longest life in the system, he wrote, "because the failure mode of a short one is a deliverability problem, and deliverability is the product" [14].

The user id is derived, not stored: base64url of the trimmed, lowercased email address, sliced to the first 60 characters, which needs no round trip to the database before the document key exists [17]. Base64 encodes three bytes per four characters, so 60 characters hold 45 bytes; any address of 45 characters or fewer decodes straight back out of the uid, and two longer addresses that agree on their first 45 bytes land on the same id [21]. The author's description is fair: "It is the email address wearing a hat" [18].

The rule he lands on is that "a verifier never accepts a token, it accepts a token for something" [19], with purpose as the type of the credential, checked in the one function nobody can route around [22]. The check belongs there. It also means the happy-path tests are useless as a guard, because they all pass with the comparison gone [8]. The test that catches the regression feeds an `unsub` token to the session reader and asserts `null`.

For a three-purpose system I would rather hold three secrets and let a wrong-purpose token fail signature verification, which turns a missing `if` into a thrown error. That costs key management the author currently does not have. He is already uneasy about the part that key rotation would not fix either: "A year is an uncomfortable time for a signed credential to sit somewhere I cannot reach" [16]. He does not say how those tokens get revoked [23].

What to watch

  • Whether a later revision of lib/auth.ts holds a separate secret per purpose, so a wrong-purpose token fails signature verification instead of a string comparison.
  • Whether the unsubscribe link moves to a stored, revocable token so the year-long credential stops being self-contained.
  • Whether the deterministic uid survives contact with a user who changes email address, since the id is derived from the address itself.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories