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

Published 2026-09-06. Updated 2026-09-17.
Canonical URL https://log.delaa.dev/blog/2026-09-06-one-time-upload-links-symfony

> 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.

---

<Image
  src="/images/single-use-capability-links-symfony/hero.png"
  alt="A single glowing link icon connecting an envelope to a locked document, with the link fading out after use"
  width={2000}
  height={1125}
  className="rounded-lg"
/>

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](https://symfony.com/doc/current/security/login_link.html)
and [UriSigner](https://symfony.com/doc/current/components/http_foundation.html#validating-signed-uris).
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 `UPDATE`s 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](/series/strangler-fig-migration) 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](https://github.com/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`](https://packagist.org/packages/mosl/magic-link-bundle),
covering the atomic consumption, the hashing, and the purpose isolation
above out of the box.

---

— Delaa
