One-Time Upload Links in Symfony, Without a User Account

Job applicants need to upload documents once, and most of them never come back. None of them are worth a user account, and there's no login flow to speak of, just a link that works once and then dies. Symfony has no first-class answer for this. Here is the actual mechanism, and where it breaks if you build it yourself.

A single glowing link icon connecting an envelope to a locked document, with the link fading out after use

A company hiring for open roles sees applications add up fast. Over a few months of postings, that's tens of thousands of them. Each applicant needs to upload a resume and supporting documents somewhere before anyone reviews them, and most will submit once and never come back. Creating a user account for each one, on the chance a small fraction eventually get hired, means maintaining tens of thousands of accounts nobody uses for people who were never meant to have one. What they actually need is a link, sent by email, that opens directly onto the upload step with no signup, no password, no account sitting unused afterward. Once they submit, the link should stop working.

That is a capability URL, a link where the URL itself is the credential. The W3C has a name for the pattern and a page describing it, but nothing in Symfony ships it as a first-class feature. Searching "single-use link no account Symfony" turns up login_link and UriSigner. Neither actually solves this.

Why the obvious tools don't fit

login_link authenticates an existing user. It resolves a token to a UserInterface and logs them in. An applicant on a first-touch upload portal is not a user in your system, and creating one just to issue a login link inverts the problem. Now you're maintaining an account for every applicant who ever clicked the link once, whether or not they were ever hired.

UriSigner gets closer but stops short. It verifies that a URL wasn't tampered with, and it does not track whether the URL was already used. "Is this signature valid" and "has this link already been consumed" are different questions, and UriSigner only answers the first. If your "single use" needs a database record you can expire, revoke, and consume exactly once, you'd still have to build all of that state tracking yourself.

What most teams build instead

Signed tokens with a manual used flag. It looks simple enough to write in an afternoon, and it is where the real bugs live.

Where the hand-rolled version breaks

The naive version looks simple. Generate a token, store it with a used boolean, check-and-flip it on the request that consumes it.

php
$link = $repository->findByToken($token);
if ($link->isUsed()) {
    throw new \RuntimeException('Already used');
}
$link->setUsed(true);
$entityManager->flush();

This has a race condition. Two requests hit the same token at nearly the same time, a user double-clicking, a link preview bot fetching it before the human does, a retried request after a timeout. Both read isUsed() === false before either has written true. Both proceed. The "single-use" guarantee is gone, and it fails silently. Nothing throws, nothing logs an error, the link is just usable twice.

The fix is an atomic conditional update, not a read-then-write.

php
$affected = $connection->executeStatement(
    'UPDATE magic_link SET consumed_at = :now
     WHERE token_hash = :hash AND consumed_at IS NULL',
    ['now' => new \DateTimeImmutable(), 'hash' => hash('sha256', $token)]
);

if ($affected === 0) {
    throw new MagicLinkConsumedException();
}

The WHERE consumed_at IS NULL makes the race impossible. The database guarantees only one of the two concurrent UPDATEs can match the row and set it. The loser gets $affected === 0 and throws. No lock, no transaction isolation tuning, just a conditional write.

The second gap, storing the token itself

The naive version usually stores the plaintext token in the database, because it's simpler to look up. That is the same mistake as storing plaintext passwords. A leaked table (a backup, a misconfigured replica, a compromised read-only credential) hands an attacker every outstanding link, permanently, with no way to know which ones were compromised.

The fix is the same as for passwords. Hash before storing.

php
$tokenHash = hash('sha256', $plaintextToken);
// store $tokenHash — never $plaintextToken

The plaintext only ever exists in memory on the request that issues it, long enough to build the URL and send it. It is never written anywhere. A leaked magic_link table gives an attacker only hashes, useless without the original 256 bits of entropy that produced them.

The third gap, one purpose, one namespace

A token minted for "confirm booking" ends up handed to the "upload documents" handler, and nothing stops it. Both endpoints just check "does this token exist and is it unused," and this one qualifies. That gap only shows up once an app has more than one kind of capability link in flight at the same time, which most eventually do. A portal link here, a booking confirmation there, a document download somewhere else, each issued from a different corner of the codebase with no shared awareness of the others.

The fix is a purpose string checked on every validation, not just token existence.

php
public function validate(string $token, string $purpose): MagicLink
{
    $link = $this->findByHash(hash('sha256', $token));

    if ($link->getPurpose() !== $purpose) {
        // never reveal which purpose it actually was — that leaks
        // information about the token to whoever is probing it
        throw new MagicLinkPurposeMismatchException();
    }

    return $link;
}

Putting it together

Three properties hold this up, and dropping any one of them breaks the guarantee.

Consumption has to be atomic. That means a conditional UPDATE rather than a read-then-write, so two concurrent requests can't both succeed on the same token. The database also never holds anything usable on its own. Only the hash sits at rest, never the token itself. And a token is scoped to exactly the flow it was issued for. Nothing else.

Expiry (a TTL, checked alongside consumed_at) and revocation (invalidate every outstanding link for a subject when a new one supersedes it, or when the underlying resource changes) round out the model, but the three above are what actually make "single-use" true rather than aspirational.

These are the kind of failures a Strangler Fig migration exposes on an authentication bridge between two frameworks. Not the happy path, but the concurrent request, the leaked table, the token reused somewhere it shouldn't be valid. This blog's Strangler Fig migration series documents that project in detail. That migration wasn't where this problem was first seen, though. The same shape of bug had come up on other projects before it, and it's that repetition, not any single incident, that made packaging the fix worth doing.

UriSigner's consumption gap is well known enough that other third-party bundles have tried to close it too. One of them, zenstruck/signed-url-bundle, added single-use support on top of it, but by requiring the consuming action to mutate some existing state, a password hash most commonly, that then invalidates the old signature. That mechanism still needs an account, something in the database to mutate. By that same logic, it never covers the account-less case this article is about, and nothing found in the Symfony ecosystem does either.

Building an atomic, hash-at-rest, purpose-namespaced store correctly by hand once is a reasonable use of an afternoon. Building it correctly by hand every time a new project needs it is not, which is why this pattern is packaged as an open-source Symfony bundle, mosl/magic-link-bundle, covering the atomic consumption, the hashing, and the purpose isolation above out of the box.


— Delaa