URL Token Portal Auth: Replacing Login With a Hash-URL Bearer
We replaced a login form and JWT session for an external customer portal with a hash-URL bearer token — no password, no cookie, no session. The refactor was small; the four things it forced us to think about afterwards were not. This is what shipping a passwordless portal into a production recruiting product taught us about rate-limit pooling, rotate-on-expired links, check-then-insert races, and proxy trust.
URL Token Portal Auth: Replacing Login With a Hash-URL Bearer
The most tempting kind of refactor is the one that deletes a subsystem. For six months our external customer portal had a login form, a password column, a JWT session, a rotate-version counter, and a dedicated brute-force limiter on the login route. It also had a support ticket rate that consisted mostly of "I can't find my password" and "the invitation link doesn't ask me for anything." We rewrote it into a single sha256-hashed bearer token that lives in the URL. No password. No cookie. No session. No JWT. This is a writeup of the URL token portal auth rewrite that shipped last week — and, more usefully, the four production gotchas that showed up after the "simple" part was done.
The rewrite lives in the AC-Center of Wield, our recruiting-intelligence product (repo name cvflow internally). The AC-Center is where customers run structured assessment days — companies book them, candidates walk in, an assessor grades exercises, and both the company and the candidate later view a report through a portal that lives outside the main application. Two audiences, both outside your identity provider, both needing time-boxed access to a specific artifact. Classic magic-link territory. The mistake we made the first time was to treat "portal" and "user" as the same problem.
The Login We Removed
The original portal shipped in July with the shape you would guess. ac_portal_credential stored a username, a bcrypt-hashed password, and an integer token_version. POST /portal/login accepted those credentials, issued a JWT with the version baked in, and every subsequent request went through a FastAPI dependency that validated the JWT, looked up the current version, and rejected the request if the two didn't match — the standard way to make "revoke this user" work without a session store. Rate limits sat on the login endpoint at one bucket per IP for password brute force, and on every other endpoint at a second bucket for authenticated abuse. Two small SQLAlchemy models, one auth module, one JWT config, one bcrypt work factor to tune, and a table full of token_version columns because that is the price of stateless sessions.
Nothing about it was wrong. It was just doing a lot of work for a feature whose whole point was one-click access to a report. Every customer who tried it hit the same wall: the invitation email arrived with a link, the link went to a login form, the login form wanted a password they didn't remember setting, they hit "forgot password", another email arrived with — a link. We had built a workflow that emailed a link to log in, so that the user could receive a link, so that the user could open a link. Twice.
The Hash-URL Bearer Design
The pattern that replaced it is boring on purpose. A single table per audience — ac_company_access for the hiring company, ac_candidate_access for the candidate — stores only the sha256 hash of a secrets.token_hex(32) string, plus the resource it grants access to and an optional expires_at. The raw token never touches the database and never leaves the URL. Every request comes in as /portal/company/{token}/… or /portal/candidate/{token}/…, a FastAPI dependency hashes the path parameter, looks up the row, checks expiry, and passes the resolved principal to the route. Rotation overwrites token_hash in place. Revocation deletes the row. There is no session, no cookie, no token_version, no login endpoint.
That deletion has one property worth being explicit about: the URL is the credential. If a customer forwards the email, whoever receives it is authenticated as them until the token is rotated or expires. That is a real trade — magic-link and payment-receipt links have the same one, and the industry has landed on "yes, that's fine, given a sensible expiry and a rotate button." For a report portal it is a perfectly good deal. For your bank, obviously not. Making the trade explicit at the design stage keeps the conversation short in review.
What we got in exchange is a serious drop in surface area. The auth module lost its JWT config, its bcrypt handler, and its token_version reconciliation branch. Two rate-limit buckets collapsed into one — there is no separate login endpoint to throttle, so the whole portal gets a single pooled 60-per-minute bucket per IP across every route (12 endpoints in our case). Retention got simpler: purging a company's access is one DELETE, not a JWT-invalidation dance. The test suite shrank while covering strictly more cases. And the failure mode "customer can't find the link" doesn't have an intermediate password step to make it worse.
Four Things That Only Show Up After the Refactor
The rewrite itself is a weekend. The four things that surfaced in the week after shipping it are the actual writeup. Every one of them was silent in staging and only became visible under production shape.
1. Rate limit pooling and the "route" you thought you were counting. With a login endpoint you can key limits per route — 5 login attempts per IP per minute is unambiguous. Without one, every portal route shares the same origin — the same customer's browser hitting every endpoint on the report page in parallel — and pooling all of them into a single per-IP bucket is what you want. But pooling is not automatic; slowapi's @limiter.limit(...) decorator by default keys per route. We consolidated to a single named limit — portal_default_limit — applied inside the require_company/require_candidate dependency rather than on the routes themselves, so the same 60/min bucket is charged whether the request hits /notes or /report. The test that catches this — and it took us a beat to figure out how to write — fires 61 real requests, spread across two different routes, against a resolvable token, and asserts a 429 with a Retry-After header. Spreading across two routes is the load-bearing detail: if you fire them all at one route the test still passes with per-route keying, and you'd never notice pooling was silently broken.
There is a subtlety in slowapi worth flagging: its decorator sits on the function body, not on middleware. If your dependency returns a 404 for an unresolvable token before the rate-limit check ever runs, an attacker can burn arbitrary CPU probing token space without hitting the limit. We tested with a real token on purpose — a garbage token would 404 first and prove nothing about the limiter.
2. Rotate on an already-expired access. A rotate button is the last-mile UX for URL tokens — the customer got the link, the link is stale, generate a new one, done. The one-line implementation is token_hash = new_hash; commit. That one line is a bug. If the row's expires_at is already in the past, the freshly rotated token is dead on arrival: the very next request through the dependency sees an expired row and returns 410, and the customer's second link is as useless as the first. The fix is to reset expires_at to NULL inside rotate when the current value is in the past, and leave any future expiry untouched. extend_expiry remains the only path that consciously sets a new deadline. This is the sort of correctness gap that unit tests don't catch until you write the two scopes — "rotate an expired token" and "rotate a token expiring next week" — as separate cases and check expires_at on both.
3. Check-then-insert is not atomic. Creating an access row starts with "does an access already exist for this order/candidate? if so, 409, otherwise insert." Under any concurrency at all — the admin clicks "issue link" twice, or a webhook and a UI action race — two requests can both pass the SELECT before either has run its INSERT. The unique constraint catches the second one, but as a raw IntegrityError that surfaces as a 500. Handling it explicitly means wrapping the insert in try / except IntegrityError, rolling back, and returning the same ServiceError the pre-check would have — 409 already exists. The race itself is not deterministically testable (that's the point of check-then-insert races), but the handler path is, and the handler path is what makes the difference between "the second admin sees a helpful error" and "the second admin sees a stack trace." Cheap, and worth putting into the pattern the moment you notice you are pre-checking uniqueness above a unique constraint.
4. Rate-limit keying depends on your proxy topology. IP-keyed limits only work if you actually know the client's IP. Behind a reverse proxy, that IP lives in X-Forwarded-For, but only in the entries appended by hops you trust. Uvicorn's ProxyHeadersMiddleware resolves it using --forwarded-allow-ips, scoped in our stack to RFC1918 ranges — safe on the assumption that Coolify's Traefik is the only hop in front of the container. Add a CDN or a WAF and that assumption breaks silently: the new hop isn't in the allowlist, Uvicorn stops trusting the chain, request.client.host reverts to the nearest proxy, and every portal client pools into one bucket. No 5xx, no log line, just a limit one busy user can exhaust for everyone. We wrote this up as a runbook next to the code rather than an inline comment, because the failure mode is a deploy-time decision, not a code-time one. Mozilla's X-Forwarded-For guidance is the reference we keep going back to.
When to Reach for URL Token Portal Auth
Three preconditions make this pattern a good fit. The audience is external to your identity provider. The access is scoped to a specific artifact rather than to "everything in the app." And the value of a one-click entry outweighs the cost of the URL being the credential. A candidate viewing their assessment report checks all three. A finance user approving a payment does not — the URL-as-credential trade is wrong for that shape, and a full auth flow with SSO is worth its weight.
Two smaller rules composed well with the pattern. Store secrets.token_hex(32) as sha256 only, applying OWASP's password storage discipline to tokens, so a database dump doesn't hand out live credentials. And keep rotation and expiry as separate operations — collapsing them into one action means either rotating unnecessarily every time an admin nudges the date, or forgetting to rotate the day someone leaked a link.
The refactor rhymes with the debounced-autosave-race finalize gate writeup from two weeks ago and, on the rate-limit side, with the FastAPI slowapi rate-limit headers post — every one is a case where deleting a moving piece is the fix, but only after you've thought through the coordination that used to hide inside it. The code got smaller. The number of things you have to be right about did not, they just moved.
If you are shipping an external portal and staring at the login-form-that-emails-a-link-that-goes-to-a-login-form loop, book a free AI Potenzial-Check — or read the Next.js worker split with pg_notify writeup for the durability half of the same "delete the moving piece" theme.
wield · The recruiting pipeline that actually scales with your volume.
CV pipeline with AI dossier generation and evaluation. For recruiters sorting a hundred applications an hour — without losing quality.