This repository was archived by the owner on Sep 20, 2026. It is now read-only.
Verify email addresses before they reach the suite - #4
Merged
Merged
Conversation
Accounts did not skip verification so much as assert it falsely. CreateNewUser stamped email_verified_at on every registration, and IdentityEntity published `email_verified` from that column -- so the claim in every id_token and userinfo response was true by construction. The verify-email view and Fortify::verifyEmailView() were already written; only the feature flag was commented out, so the route they served did not exist. That mattered because the apps downstream match on the address. Invitations are found by `pendingFor($user->email)` and accepted on a lowercased string comparison, and the SSO callback adopts a pre-existing local account by email. An address nobody had proved they held was enough to claim either. Turn the feature on, implement MustVerifyEmail so the framework's checks actually fire, and stop stamping the column at registration. Existing users keep the verified_at they already have: they are accounts we created or know, and re-verifying them would lock them out to prove a point. Registration signs the new user in before they have opened the link, so the authorization screen is the door that had to be shut -- otherwise they walk straight out with tokens. EnsureEmailIsVerifiedForOAuth sits on the Passport route group and redirects them to the notice instead. It reads a user rather than demanding one, because that group also carries /oauth/token and /oauth/userinfo, which the client calls with no session to read. Passport applies its configured middleware as *group* middleware, which puts it ahead of the `web` group and so ahead of the session the gate reads. Left that way the gate resolves a null user and passes everyone through while looking correct, so bootstrap/app.php names it in the priority list after StartSession. Confirmed against a real server: unverified user, cold request, 302 to /email/verify with that entry and 200 without it. The request tests cannot see that difference -- Laravel's test client keeps one session store alive across the requests in a test, so the store is already started by the time the gate runs and the user resolves either way. The ordering test is the guard that does catch it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
Accounts didn't skip verification so much as assert it falsely.
CreateNewUserran$user->forceFill(['email_verified_at' => now()])->save()on every registration.IdentityEntitypublishes'email_verified' => $this->user->hasVerifiedEmail()into every id_token and/oauth/userinforesponse — which, given the line above, wastrueby construction.Features::emailVerification()was commented out, soverification.*routes didn't exist.resources/views/auth/verify-email.blade.phpand theFortify::verifyEmailView()registration were already written and simply dead.UpdateUserProfileInformationhad aninstanceof MustVerifyEmailbranch to re-verify on email change.Userdidn't implement the contract, so that branch never ran and emails changed silently.This mattered because the suite apps match on the address. Invitations are found by
pendingFor($user->email)and accepted on a lowercased string comparison, and the SSO callback adopts a pre-existing local account by email. An unproven address was enough to claim either.What changed
Features::emailVerification()enabled;User implements MustVerifyEmailso the framework's checks actually fire.CreateNewUserno longer stamps the column — registration firesRegistered, which sends the link./homeand/oauth/switch-accountnow requireverified.EnsureEmailIsVerifiedForOAuthon the Passport route group.Existing users are grandfathered. All 6 keep the
email_verified_atthey already have. No migration, nobody locked out, no surprise re-verification mail.The part that needed care
Registration signs the new user in before they open the link, so the authorization screen is the door that actually had to be shut — otherwise they walk straight out with tokens.
The gate reads a user rather than demanding one, because the Passport group also carries
/oauth/tokenand/oauth/userinfo, which the client calls with no session.And Passport applies
config('passport.middleware')as group middleware, which places it ahead of thewebgroup — and so ahead of the session the gate reads. Left that way it resolves a null user and passes everyone through while looking perfectly correct.bootstrap/app.phpnames it in the priority list afterStartSession.Confirmed against a real server (cold request, unverified user):
/oauth/authorize/email/verifyA verified user still gets the continue-as screen (200).
Testing
28 passed, up from 16. Pint clean.
One honest limitation, documented in the test file: the two request-level gate tests cannot catch the ordering bug. Laravel's test client keeps one session store alive across requests within a test, so it's already started by the time the gate runs and the user resolves either way. The
the gate is ordered after the session it readstest is the guard that does catch it — verified by removing the priority entry and watching it fail.Note for the follow-up
With this gate in place, an unverified address can no longer obtain tokens at all, so the client apps are protected without changing them. Making the clients also honour the
email_verifiedclaim is still worth doing as defence in depth, and pairs naturally with removing their local email-edit field.🤖 Generated with Claude Code