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.
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".
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 response timing does not leak existence. 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 that work is real. 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 — there is no path that authenticates a two-factor user without the second factor.
Brute force of one-time codesA boot-time entropy guardrail refuses to start 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 IP address, 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 contain the damage. Both steps that spend a credential 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 a forged host is therefore only redeemable at that host, where the attacker already controls everything, and replaying it against the real application is refused.

That's a smaller promise than deciding the host yourself, and it isn't a substitute for it. An attacker who reads the token still holds a credential the victim believes is theirs. 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.