Skip to main content

Security model

The package is designed to fail closed. Each row below is a concrete threat and the design decision that addresses it — every one is exercised by the test suite.

ThreatHow the package addresses it
Database leak — a stolen backup, an exposed read replica, or SQL injection elsewhere in the applicationTokens and codes are never stored in the clear: only a keyed HMAC-SHA256 hash is persisted and indexed. A leaked database alone cannot recognize or forge a working link or code. The queued mail that carries the plaintext is encrypted on the queue with the application key, because Laravel's default queue is a table in that same database, and a job that fails stays in failed_jobs. A link, code or invitation URL you mint and queue yourself is yours to encrypt: give that mailable or job ShouldBeEncrypted.
Look-alike addresses — a registrable domain that differs from the victim's by an accentA link or code goes to the address the account states, never to the typed string, and an account resolves only for its own address. MySQL's default collations match victim@exämple.com to the account stored as victim@example.com; the package refuses that match and answers as it does for an unknown address.
Email security scanners and prefetch — SafeLinks, Mimecast, Proofpoint, browser preconnectThe emailed GET is signed and inert — it only renders a confirmation page, with no authentication or state change. The single-use token is spent solely by an explicit POST, so a link-follower cannot burn it before the human clicks "Sign in".
The token leaking through the Referer header — a stylesheet from a CDN, a link to another siteThe confirmation and invitation pages live at a URL whose path is the credential. Every response of the package's routes sets Referrer-Policy: same-origin, and both bundled layouts repeat it in a <meta name="referrer"> ahead of anything the page loads, so another origin never receives that URL, whatever referrer policy the rest of the application sends. The application itself still gets the header, which is what redirect()->back() reads.
Token replay, double-spend, and racesConsumption is a single race-free conditional claim (PostgreSQL RETURNING, with a portable affected-rows fallback), so two concurrent requests for the same token can never both succeed. Links and codes are single-use unless a bounded max_uses is set, and that counter is decremented in the same statement.
Object injection and deserialization gadgetsThe package never serializes objects into a token. A row holds only scalar columns (user id, guard, channel, a hash, timestamps), so there is no unserialize() on any code path and therefore no object-injection surface.
Account enumerationThe request endpoint returns a response identical whether or not the email belongs to a user, runs the optional CAPTCHA before any lookup, and queues the mail, so the response never waits for its delivery. Under contention the status is identical too: only an address that resolves to a user reaches the issuance lock, so a lock timeout surfacing as an error would have been an oracle for anyone willing to send two requests at once — it is caught and answered like any other request. Since 0.26.0 the timing is identical too: this endpoint no longer queues for the lock at all. It gives up at once and answers as it would have anyway, because the request holding the lock is the one sending the credential. Measured before the change, with lock_block_seconds at 1: 815 ms for a known contended address against 12 ms for an unknown one, and five times that at the default — a difference the caller produced on demand by sending two requests at once. A regression arm measures that difference through the endpoint rather than the call that closed it, so it would notice the channel reopening for a reason nobody predicted. lock_block_seconds still governs the programmatic issuers, where no response shape depends on how long a call took. What this does not claim is a constant-time endpoint: issuing writes a row and queues a mail, and that work is real. Measured in-process over 1,000 interleaved requests per address, on PostgreSQL 18 and MySQL 8.0 with the database queue, a known address answers about 1.4 ms later than an unknown one, and a wrong code for an address with a live code 1.3 to 2.1 ms later. What bounds that is how many samples an attacker gets: the resend guard counts every request for an address before any lookup, five an hour by default, and the consume limiter allows ten guesses a minute for an address. The claim is the narrower and checkable one — no wait budget is spent on one branch and not on the other. Consume failures collapse to a single generic message.
Two-factor bypassA user with two-factor authentication enabled is handed to Fortify's challenge without being logged in, and the login completes inside Fortify only after the second factor. Two settings turn the handoff off, and both are a decision to let the link alone sign such a user in: fortify.mode set to false, and fortify.respect_two_factor set to false, which logs a warning on every boot. A blank EMAIL_MAGIC_LINK_FORTIFY counts as unset and keeps the handoff on.
Brute force of one-time codesA boot-time entropy guardrail refuses to start, in every mode, when a code's keyspace divided by the per-token attempt cap is too low; a per-token lockout burns the token after too many wrong guesses; and the endpoints are rate-limited per email, per source (an IPv4 address, or an IPv6 address by its /64), and per token hash.
Mail flooding an inbox — a repeatedly clicked "send again"Beyond the per-minute limiters, a resend guard applies an escalating cooldown (30s, then 60s, then 120s …) and a rolling cap (five per hour) keyed per email, so a victim's inbox cannot be flooded. It is keyed on the submitted address alone, never on whether it resolves to a user, so it stays enumeration-safe.
Session fixationThe session id is regenerated on a successful login.

Each row is expanded elsewhere in these pages: the two-factor handoff, the entropy guardrail, the resend guard, multi-use links, and why the flow costs one extra click.

The link in the email is built the way Laravel builds every URL: from the application's forced origin if one is set, otherwise from the request's Host header. That is a framework property, and Password::sendResetLink() shares it. On a deployment whose web server answers any host — a catch-all virtual host, a load balancer that forwards whatever it was sent — a request that asks for a link for someone else's address can carry a forged Host, and the email then points at that host with a valid token in the path.

The signature does not help here: it binds the link to the host it was built for, and it was built for the forged one. What closes it is deciding the host yourself, in one of two ways:

// bootstrap/app.php — answer only the hosts you own
->withMiddleware(function (Middleware $middleware) {
$middleware->trustHosts(at: ['example.com', '*.example.com']);
})

// or, in a service provider's boot(): build every URL from APP_URL
URL::useOrigin(config('app.url'));

php artisan email-magic-link:doctor reports which of the three the application is on: a forced origin, trusted hosts, or the request as it came in.

What the package does on its own is bind each credential to a host name. Both steps that spend one verify the same signature the emailed link carries: the POST that consumes a magic link and the POST that accepts an invitation. A token minted for one host is refused at any other, so a link built for one tenant's host cannot be spent at another's.

That binding does not stop a forged host, and it isn't a substitute for deciding the host yourself. The signature names a host, not a server: an application that answers any Host header accepts the replay when the attacker sends the forged name along with the token, and signs the attacker in. Only the application can refuse a host it does not own. Set trustHosts or useOrigin.

Rotating the application key​

Tokens are stored as an HMAC keyed with APP_KEY, so a copy of the database is not a way in. That key is also what finds a row again when somebody clicks their link, which makes rotating it a question with a real answer.

Laravel's gentle rotation works here. Put the outgoing key in app.previous_keys and the package still finds tokens issued under it, while everything new is written under the current key alone. Drop the old key from that list and those tokens stop resolving. That's the point, and dropping it is the moment that actually retires the key, not the rotation itself.

Rotating without retiring the old key invalidates everything still outstanding. For sign-in links that is barely visible at a fifteen-minute lifetime. For invitations it is not: they live seven days by default, and every open one would come back as the same generic refusal an unknown token gets, with nothing to say why.

What is never logged​

Raw tokens and full link URLs are never logged.

Backing off​

Throttled responses carry the standard Retry-After and X-RateLimit-* headers, so API and SPA clients can back off correctly. See The JSON contract.

The one availability trade-off​

The resend cap is a per-account control keyed on the submitted email and enforced before user lookup. Because it can be used to keep a known address throttled, pair it with the captcha guard — which runs first — in hostile settings. The full reasoning is under the hourly cap and availability.

For high-risk deployments, layer a CAPTCHA or challenge widget on top via the captcha guard — see Gate requests with a CAPTCHA.

Reporting a vulnerability​

The supported versions and how to report a vulnerability are in SECURITY.md.