VYPR
High severity8.1NVD Advisory· Published Jun 11, 2026· Updated Jun 11, 2026

CVE-2026-46622

CVE-2026-46622

Description

SolidInvoice stores API tokens as plaintext in the database, allowing credential theft on any read access.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

SolidInvoice stores API tokens as plaintext in the database, allowing credential theft on any read access.

Vulnerability

SolidInvoice versions prior to 2.3.17 store API tokens in the api_tokens database table as plaintext strings [1]. The tokens are generated using bin2hex(random_bytes(32)), producing a 64-character hex string, but the value is written directly to the database without hashing [2]. The authentication lookup in ApiTokenRepository uses a plaintext equality check in SQL [2]. This affects all SolidInvoice installations prior to the fix.

Exploitation

An attacker who obtains read access to the database—via SQL injection, a leaked backup, a misconfigured replica, or insider access—can simply query SELECT token, user_id FROM api_tokens and use any returned token directly with a request such as curl -H "X-API-TOKEN: " https://target.com/api/invoices [2]. No further exploitation is required.

Impact

Successful exploitation gives the attacker full API access with the privileges of the corresponding user [2]. The attacker can impersonate any user, exfiltrate all invoices, clients, and payment data, and perform any action the API permits [2]. Tokens do not rotate automatically after a breach, so the compromise is permanent until manual rotation occurs [2].

Mitigation

The issue has been patched in SolidInvoice version 2.3.17 [1][3]. The fix introduces API token hashing via ApiTokenHasher, which stores a hash of the token instead of the plaintext value [1][2]. Users should upgrade to version 2.3.17 or later immediately [3]. After upgrading, existing tokens should be rotated to ensure all stored credentials are hashed [2].

AI Insight generated on Jun 11, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

1
864539182572

Hash all API tokens in DB

https://github.com/solidinvoice/solidinvoicePierre du PlessisMay 15, 2026via nvd-ref
16 files changed · +340 106
  • src/ApiBundle/ApiTokenManager.php+17 12 modified
    @@ -14,6 +14,7 @@
     namespace SolidInvoice\ApiBundle;
     
     use Doctrine\Persistence\ManagerRegistry;
    +use SolidInvoice\ApiBundle\Security\ApiTokenHasher;
     use SolidInvoice\UserBundle\Entity\ApiToken;
     use SolidInvoice\UserBundle\Entity\User;
     
    @@ -25,38 +26,42 @@ class ApiTokenManager
         final public const TOKEN_LENGTH = 32;
     
         public function __construct(
    -        private readonly ManagerRegistry $registry
    +        private readonly ManagerRegistry $registry,
    +        private readonly ApiTokenHasher $hasher,
         ) {
         }
     
    -    public function getOrCreate(User $user, string $name): ApiToken
    +    /**
    +     * Returns an existing token entity by name when present, otherwise creates
    +     * a new one. When an existing token is returned, the plaintext is not
    +     * available (it is never persisted) and {@see GeneratedApiToken::$plaintext}
    +     * is an empty string.
    +     */
    +    public function getOrCreate(User $user, string $name): GeneratedApiToken
         {
    -        $tokens = $user->getApiTokens();
    -
    -        /** @var ApiToken $token */
    -        foreach ($tokens as $token) {
    +        foreach ($user->getApiTokens() as $token) {
                 if ($token->getName() === $name) {
    -                return $token;
    +                return new GeneratedApiToken($token, '');
                 }
             }
     
             return $this->create($user, $name);
         }
     
    -    public function create(User $user, string $name): ApiToken
    +    public function create(User $user, string $name): GeneratedApiToken
         {
    -        $apiToken = new ApiToken();
    +        $plaintext = $this->generateToken();
     
    -        $apiToken->setToken($this->generateToken());
    +        $apiToken = new ApiToken();
    +        $apiToken->setToken($this->hasher->hash($plaintext));
             $apiToken->setUser($user);
             $apiToken->setName($name);
     
             $entityManager = $this->registry->getManager();
    -
             $entityManager->persist($apiToken);
             $entityManager->flush();
     
    -        return $apiToken;
    +        return new GeneratedApiToken($apiToken, $plaintext);
         }
     
         public function generateToken(): string
    
  • src/ApiBundle/Event/Listener/AuthenticationSuccessHandler.php+24 4 modified
    @@ -24,7 +24,7 @@
     class AuthenticationSuccessHandler implements AuthenticationSuccessHandlerInterface
     {
         public function __construct(
    -        private readonly ApiTokenManager $tokenManager
    +        private readonly ApiTokenManager $tokenManager,
         ) {
         }
     
    @@ -33,8 +33,28 @@ public function onAuthenticationSuccess(Request $request, TokenInterface $token)
             /** @var User $user */
             $user = $token->getUser();
     
    -        $token = $this->tokenManager->getOrCreate($user, $request->request->get('token_name') ?: 'API Token');
    -
    -        return new JsonResponse(['token' => $token->getToken()]);
    +        $name = $request->request->get('token_name') ?: 'API Token';
    +
    +        // Tokens are stored as hashes, so we cannot return the plaintext of an
    +        // existing token. Refuse to silently invalidate one — the caller must
    +        // pick a unique name or revoke the existing token first.
    +        foreach ($user->getApiTokens() as $existing) {
    +            if ($existing->getName() === $name) {
    +                return new JsonResponse(
    +                    [
    +                        'error' => 'token_name_already_exists',
    +                        'message' => sprintf(
    +                            'An API token named "%s" already exists. Revoke it first or pass a different "token_name" parameter.',
    +                            $name,
    +                        ),
    +                    ],
    +                    Response::HTTP_CONFLICT,
    +                );
    +            }
    +        }
    +
    +        $generated = $this->tokenManager->create($user, $name);
    +
    +        return new JsonResponse(['token' => $generated->plaintext]);
         }
     }
    
  • src/ApiBundle/GeneratedApiToken.php+29 0 added
    @@ -0,0 +1,29 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +/*
    + * This file is part of SolidInvoice project.
    + *
    + * (c) Pierre du Plessis <open-source@solidworx.co>
    + *
    + * This source file is subject to the MIT license that is bundled
    + * with this source code in the file LICENSE.
    + */
    +
    +namespace SolidInvoice\ApiBundle;
    +
    +use SolidInvoice\UserBundle\Entity\ApiToken;
    +
    +/**
    + * The plaintext token is only available at creation time — it is never
    + * persisted nor recoverable from the database afterwards.
    + */
    +final class GeneratedApiToken
    +{
    +    public function __construct(
    +        public readonly ApiToken $token,
    +        public readonly string $plaintext,
    +    ) {
    +    }
    +}
    
  • src/ApiBundle/Security/ApiTokenAuthenticator.php+6 5 modified
    @@ -16,9 +16,9 @@
     use Doctrine\Persistence\ManagerRegistry;
     use SolidInvoice\ApiBundle\Security\Provider\ApiTokenUserProvider;
     use SolidInvoice\CoreBundle\Company\CompanySelector;
    -use SolidInvoice\UserBundle\Entity\ApiToken;
     use SolidInvoice\UserBundle\Entity\ApiTokenHistory;
     use SolidInvoice\UserBundle\Repository\ApiTokenHistoryRepository;
    +use SolidInvoice\UserBundle\Repository\ApiTokenRepository;
     use Symfony\Component\HttpFoundation\JsonResponse;
     use Symfony\Component\HttpFoundation\Request;
     use Symfony\Component\HttpFoundation\Response;
    @@ -37,7 +37,8 @@ public function __construct(
             private readonly ApiTokenUserProvider $userProvider,
             private readonly ManagerRegistry $registry,
             private readonly TranslatorInterface $translator,
    -        private readonly CompanySelector $companySelector
    +        private readonly CompanySelector $companySelector,
    +        private readonly ApiTokenRepository $apiTokenRepository,
         ) {
         }
     
    @@ -63,10 +64,10 @@ public function onAuthenticationSuccess(Request $request, TokenInterface $token,
     
             $repository->addHistory($history, $apiToken);
     
    -        $apiToken = $this->registry->getRepository(ApiToken::class)->findOneBy(['token' => $apiToken]);
    +        $apiTokenEntity = $this->apiTokenRepository->findOneByPlaintext($apiToken);
     
    -        if (null !== $apiToken) {
    -            $this->companySelector->switchCompany($apiToken->getCompany()->getId());
    +        if (null !== $apiTokenEntity) {
    +            $this->companySelector->switchCompany($apiTokenEntity->getCompany()->getId());
             }
     
             return null;
    
  • src/ApiBundle/Security/ApiTokenHasher.php+39 0 added
    @@ -0,0 +1,39 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +/*
    + * This file is part of SolidInvoice project.
    + *
    + * (c) Pierre du Plessis <open-source@solidworx.co>
    + *
    + * This source file is subject to the MIT license that is bundled
    + * with this source code in the file LICENSE.
    + */
    +
    +namespace SolidInvoice\ApiBundle\Security;
    +
    +use Symfony\Component\DependencyInjection\Attribute\Autowire;
    +
    +/**
    + * Hashes API tokens with HMAC-SHA256 keyed by the application secret.
    + *
    + * Why HMAC and not bcrypt/argon2: authentication needs a deterministic
    + * equality lookup on an indexed column; per-row salts would force a full
    + * table scan. Token entropy is already 256 bits of random_bytes, so a
    + * pepper keyed by the app secret is sufficient. A DB-only leak yields
    + * no usable hashes without also having the secret.
    + */
    +final class ApiTokenHasher
    +{
    +    public function __construct(
    +        #[Autowire('%kernel.secret%')]
    +        private readonly string $appSecret,
    +    ) {
    +    }
    +
    +    public function hash(string $plaintextToken): string
    +    {
    +        return hash_hmac('sha256', $plaintextToken, $this->appSecret);
    +    }
    +}
    
  • src/ApiBundle/Test/ApiTestCase.php+2 2 modified
    @@ -108,9 +108,9 @@ protected function setUp(): void
             $user = UserFactory::createOne(['companies' => [$this->company]])->_real();
     
             $tokenManager = self::getContainer()->get(ApiTokenManager::class);
    -        $token = $tokenManager->getOrCreate($user, 'Functional Test');
    +        $generated = $tokenManager->getOrCreate($user, 'Functional Test');
     
    -        self::$client = static::createClient(defaultOptions: ['headers' => ['X-API-TOKEN' => $token->getToken()]]);
    +        self::$client = static::createClient(defaultOptions: ['headers' => ['X-API-TOKEN' => $generated->plaintext]]);
     
             // We need to switch the company again,
             // because the ::createClient call resets the container
    
  • src/ApiBundle/Tests/ApiTokenManagerTest.php+36 20 modified
    @@ -21,16 +21,25 @@
     use Mockery as M;
     use PHPUnit\Framework\TestCase;
     use SolidInvoice\ApiBundle\ApiTokenManager;
    +use SolidInvoice\ApiBundle\GeneratedApiToken;
    +use SolidInvoice\ApiBundle\Security\ApiTokenHasher;
     use SolidInvoice\UserBundle\Entity\ApiToken;
     use SolidInvoice\UserBundle\Entity\User;
     
     class ApiTokenManagerTest extends TestCase
     {
         use MockeryPHPUnitIntegration;
     
    +    private const SECRET = 'unit-test-secret';
    +
    +    private function hasher(): ApiTokenHasher
    +    {
    +        return new ApiTokenHasher(self::SECRET);
    +    }
    +
         public function testGenerateToken(): void
         {
    -        $tm = new ApiTokenManager(M::mock(ManagerRegistry::class));
    +        $tm = new ApiTokenManager(M::mock(ManagerRegistry::class), $this->hasher());
     
             $token = $tm->generateToken();
     
    @@ -39,7 +48,7 @@ public function testGenerateToken(): void
             self::assertMatchesRegularExpression('/[a-zA-Z0-9]{64}/', $token);
         }
     
    -    public function testCreate(): void
    +    public function testCreateStoresHashAndReturnsPlaintext(): void
         {
             $registry = M::mock(ManagerRegistry::class);
     
    @@ -58,16 +67,23 @@ public function testCreate(): void
             $manager->shouldReceive('flush')
                 ->withNoArgs();
     
    -        $tm = new ApiTokenManager($registry);
    +        $tm = new ApiTokenManager($registry, $this->hasher());
     
    -        $token = $tm->create($user, 'test token');
    +        $generated = $tm->create($user, 'test token');
     
    -        self::assertInstanceOf(ApiToken::class, $token);
    -        self::assertSame($user, $token->getUser());
    -        self::assertSame('test token', $token->getName());
    +        self::assertInstanceOf(GeneratedApiToken::class, $generated);
    +        self::assertInstanceOf(ApiToken::class, $generated->token);
    +        self::assertSame($user, $generated->token->getUser());
    +        self::assertSame('test token', $generated->token->getName());
    +        self::assertSame(64, strlen($generated->plaintext));
    +        self::assertNotSame($generated->plaintext, $generated->token->getToken());
    +        self::assertSame(
    +            hash_hmac('sha256', $generated->plaintext, self::SECRET),
    +            $generated->token->getToken(),
    +        );
         }
     
    -    public function testGet(): void
    +    public function testGetReturnsExistingTokenWithoutPlaintext(): void
         {
             $registry = M::mock(ManagerRegistry::class);
     
    @@ -83,15 +99,15 @@ public function testGet(): void
             $apiTokens = new ArrayCollection([$token1, $token2]);
             $user->setApiTokens($apiTokens);
     
    -        $tm = new ApiTokenManager($registry);
    +        $tm = new ApiTokenManager($registry, $this->hasher());
     
    -        $token = $tm->getOrCreate($user, 'token1');
    +        $generated = $tm->getOrCreate($user, 'token1');
     
    -        self::assertInstanceOf(ApiToken::class, $token);
    -        self::assertSame($token1, $token);
    +        self::assertSame($token1, $generated->token);
    +        self::assertSame('', $generated->plaintext);
         }
     
    -    public function testGetOrCreate(): void
    +    public function testGetOrCreateCreatesWhenMissing(): void
         {
             $registry = M::mock(ManagerRegistry::class);
     
    @@ -120,14 +136,14 @@ public function testGetOrCreate(): void
             $manager->shouldReceive('flush')
                 ->withNoArgs();
     
    -        $tm = new ApiTokenManager($registry);
    +        $tm = new ApiTokenManager($registry, $this->hasher());
     
    -        $token = $tm->getOrCreate($user, 'token3');
    +        $generated = $tm->getOrCreate($user, 'token3');
     
    -        self::assertInstanceOf(ApiToken::class, $token);
    -        self::assertNotSame($token1, $token);
    -        self::assertNotSame($token2, $token);
    -        self::assertSame($user, $token->getUser());
    -        self::assertSame('token3', $token->getName());
    +        self::assertNotSame($token1, $generated->token);
    +        self::assertNotSame($token2, $generated->token);
    +        self::assertSame($user, $generated->token->getUser());
    +        self::assertSame('token3', $generated->token->getName());
    +        self::assertNotSame('', $generated->plaintext);
         }
     }
    
  • src/ApiBundle/Tests/Security/ApiTokenHasherTest.php+59 0 added
    @@ -0,0 +1,59 @@
    +<?php
    +
    +declare(strict_types=1);
    +
    +/*
    + * This file is part of SolidInvoice project.
    + *
    + * (c) Pierre du Plessis <open-source@solidworx.co>
    + *
    + * This source file is subject to the MIT license that is bundled
    + * with this source code in the file LICENSE.
    + */
    +
    +namespace SolidInvoice\ApiBundle\Tests\Security;
    +
    +use PHPUnit\Framework\TestCase;
    +use SolidInvoice\ApiBundle\Security\ApiTokenHasher;
    +
    +final class ApiTokenHasherTest extends TestCase
    +{
    +    public function testHashIsDeterministic(): void
    +    {
    +        $hasher = new ApiTokenHasher('secret');
    +
    +        self::assertSame($hasher->hash('plaintext'), $hasher->hash('plaintext'));
    +    }
    +
    +    public function testHashDiffersForDifferentSecrets(): void
    +    {
    +        $a = new ApiTokenHasher('secret-a');
    +        $b = new ApiTokenHasher('secret-b');
    +
    +        self::assertNotSame($a->hash('plaintext'), $b->hash('plaintext'));
    +    }
    +
    +    public function testHashDiffersForDifferentInputs(): void
    +    {
    +        $hasher = new ApiTokenHasher('secret');
    +
    +        self::assertNotSame($hasher->hash('one'), $hasher->hash('two'));
    +    }
    +
    +    public function testHashIsSha256HexDigest(): void
    +    {
    +        $hasher = new ApiTokenHasher('secret');
    +
    +        $hash = $hasher->hash('plaintext');
    +
    +        self::assertSame(64, strlen($hash));
    +        self::assertMatchesRegularExpression('/^[a-f0-9]{64}$/', $hash);
    +    }
    +
    +    public function testHashDoesNotLeakPlaintext(): void
    +    {
    +        $hasher = new ApiTokenHasher('secret');
    +
    +        self::assertNotSame('plaintext', $hasher->hash('plaintext'));
    +    }
    +}
    
  • src/UserBundle/Repository/ApiTokenHistoryRepository.php+12 5 modified
    @@ -16,6 +16,7 @@
     use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
     use Doctrine\Common\Collections\Order;
     use Doctrine\Persistence\ManagerRegistry;
    +use SolidInvoice\ApiBundle\Security\ApiTokenHasher;
     use SolidInvoice\UserBundle\Entity\ApiToken;
     use SolidInvoice\UserBundle\Entity\ApiTokenHistory;
     use Symfony\Bridge\Doctrine\Types\UlidType;
    @@ -25,19 +26,25 @@
      */
     class ApiTokenHistoryRepository extends ServiceEntityRepository
     {
    -    public function __construct(ManagerRegistry $registry)
    -    {
    +    public function __construct(
    +        ManagerRegistry $registry,
    +        private readonly ApiTokenHasher $hasher,
    +    ) {
             parent::__construct($registry, ApiTokenHistory::class);
         }
     
    -    public function addHistory(ApiTokenHistory $history, string $token): void
    +    public function addHistory(ApiTokenHistory $history, string $plaintextToken): void
         {
             $entityManager = $this->getEntityManager();
     
    -        /** @var ApiToken $apiToken */
    +        /** @var ApiToken|null $apiToken */
             $apiToken = $entityManager
                 ->getRepository(ApiToken::class)
    -            ->findOneBy(['token' => $token]);
    +            ->findOneBy(['token' => $this->hasher->hash($plaintextToken)]);
    +
    +        if (null === $apiToken) {
    +            return;
    +        }
     
             $apiToken->addHistory($history);
     
    
  • src/UserBundle/Repository/ApiTokenRepository.php+22 7 modified
    @@ -17,6 +17,7 @@
     use Doctrine\ORM\NonUniqueResultException;
     use Doctrine\ORM\NoResultException;
     use Doctrine\Persistence\ManagerRegistry;
    +use SolidInvoice\ApiBundle\Security\ApiTokenHasher;
     use SolidInvoice\UserBundle\Entity\ApiToken;
     use SolidInvoice\UserBundle\Entity\ApiTokenHistory;
     use SolidInvoice\UserBundle\Entity\User;
    @@ -28,19 +29,25 @@
     
     class ApiTokenRepository extends ServiceEntityRepository
     {
    -    public function __construct(ManagerRegistry $registry)
    -    {
    +    public function __construct(
    +        ManagerRegistry $registry,
    +        private readonly ApiTokenHasher $hasher,
    +    ) {
             parent::__construct($registry, ApiToken::class);
         }
     
    -    public function getUsernameForToken(string $token): ?string
    +    /**
    +     * Looks up the username for a given plaintext API token. The token is
    +     * hashed before the query so the database only ever sees the hash.
    +     */
    +    public function getUsernameForToken(string $plaintextToken): ?string
         {
             $q = $this
                 ->createQueryBuilder('t')
                 ->select('u.email')
                 ->join('t.user', 'u')
                 ->where('t.token = :token')
    -            ->setParameter('token', $token)
    +            ->setParameter('token', $this->hasher->hash($plaintextToken))
                 ->getQuery();
     
             try {
    @@ -52,14 +59,23 @@ public function getUsernameForToken(string $token): ?string
         }
     
         /**
    -     * @return array{id: mixed, name: mixed, ip: mixed, token: mixed, lastUsed: mixed}
    +     * Finds an ApiToken entity by its plaintext value. Returns null when no
    +     * matching token exists.
    +     */
    +    public function findOneByPlaintext(string $plaintextToken): ?ApiToken
    +    {
    +        return $this->findOneBy(['token' => $this->hasher->hash($plaintextToken)]);
    +    }
    +
    +    /**
    +     * @return list<array{id: mixed, name: mixed, ip: mixed, lastUsed: mixed}>
          */
         public function getApiTokensForUser(UserInterface $user): array
         {
             assert($user instanceof User);
     
             $tokens = $this->createQueryBuilder('t')
    -            ->select('t.id', 't.name', 't.token')
    +            ->select('t.id', 't.name')
                 ->where('t.user = :user')
                 ->orderBy('t.created', 'DESC')
                 ->setParameter('user', $user->getId(), UlidType::NAME)
    @@ -80,7 +96,6 @@ public function getApiTokensForUser(UserInterface $user): array
                 'id' => $token['id'],
                 'name' => $token['name'],
                 'ip' => $historyMap[$token['id']->toBinary()]['ip'] ?? null,
    -            'token' => $token['token'],
                 'lastUsed' => $historyMap[$token['id']->toBinary()]['max_created'] ?? null,
             ], $tokens);
         }
    
  • src/UserBundle/Resources/translations/messages.en.yml+6 0 modified
    @@ -29,6 +29,12 @@ profile:
                     text: '%date% from %ip%'
                     never: Never
                     last: 'Last Used:'
    +            created:
    +                title: 'Your new API token'
    +                done: 'Done'
    +                warning:
    +                    title: 'Copy this token now.'
    +                    body: 'For security reasons it will not be shown again. If you lose it, revoke this token and create a new one.'
             form:
                 title: 'Create API Token'
                 actions:
    
  • src/UserBundle/Resources/views/Components/ApiTokens.html.twig+19 37 modified
    @@ -15,44 +15,26 @@
                                         {{ token.name }}
                                     </h6>
                                     <p>
    -                                    <div class="input-group" {{ stimulus_controller('password-visibility', controllerClasses = { 'hidden': 'd-none' })|stimulus_controller('clipboard', {'success-content': 'Copied!'}) }}>
    -                                        <div class="input-group-prepend">
    -                                            <a href="#" class="btn btn-default btn-sm pt-2" title="{{ 'Copy to clipboard'|trans }}" rel="tooltip" {{ stimulus_target('clipboard', 'button') }} {{ stimulus_action('clipboard', 'copy') }}>
    -                                                {{ icon('paste')}}
    -                                            </a>
    -                                        </div>
    -                                        <div class="input-group-prepend">
    -                                            <a href="#" class="btn btn-default btn-sm pt-2" title="{{ 'View Token'|trans }}" rel="tooltip" {{ stimulus_action('password-visibility', 'toggle') }}">
    -                                                <span {{ stimulus_target('password-visibility', 'icon') }}>
    -                                                    {{ icon('eye')}}
    -                                                </span>
    -                                                <span {{ stimulus_target('password-visibility', 'icon') }} class="d-none">
    -                                                    {{ icon('eye-slash')}}
    -                                                </span>
    -                                            </a>
    -                                        </div>
    -                                        <input readonly="readonly" value="{{ token.token }}" class="form-control" type="password" {{ stimulus_target('clipboard', 'source') }} {{ stimulus_target('password-visibility', 'input') }} spellcheck="false" />
    -                                        <div class="input-group-append">
    -                                            <a href="#" class="btn btn-danger btn-sm revoke-token pt-2" data-toggle="modal" data-target="#{{ 'revoke-' ~ token.id }}">
    -                                                {{ icon('ban')}} {{ 'profile.api.tokens.revoke'|trans }}
    -                                            </a>
    +                                    <div class="float-right">
    +                                        <a href="#" class="btn btn-danger btn-sm revoke-token" data-toggle="modal" data-target="#{{ 'revoke-' ~ token.id }}">
    +                                            {{ icon('ban') }} {{ 'profile.api.tokens.revoke'|trans }}
    +                                        </a>
     
    -                                            {% component BootstrapModal with {id: 'revoke-' ~ token.id} %}
    -                                                {% block modal_header %}
    -                                                    <h5>{{ 'Confirm'|trans }}</h5>
    -                                                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">
    -                                                        <span aria-hidden="true">&times;</span>
    -                                                    </button>
    -                                                {% endblock %}
    -                                                {% block modal_body %}
    -                                                    {{ 'Are you sure you want to revoke this token?'|trans }}
    -                                                {% endblock %}
    -                                                {% block modal_footer %}
    -                                                    <button type="button" class="btn btn-default" data-dismiss="modal">{{ 'Close'|trans }}</button>
    -                                                    <button type="button" class="btn btn-primary" {{ live_action('revoke:prevent', {'token': token.id}) }}>{{ 'Confirm'|trans }}</button>
    -                                                {% endblock %}
    -                                            {% endcomponent %}
    -                                        </div>
    +                                        {% component BootstrapModal with {id: 'revoke-' ~ token.id} %}
    +                                            {% block modal_header %}
    +                                                <h5>{{ 'Confirm'|trans }}</h5>
    +                                                <button type="button" class="close" data-dismiss="modal" aria-label="Close">
    +                                                    <span aria-hidden="true">&times;</span>
    +                                                </button>
    +                                            {% endblock %}
    +                                            {% block modal_body %}
    +                                                {{ 'Are you sure you want to revoke this token?'|trans }}
    +                                            {% endblock %}
    +                                            {% block modal_footer %}
    +                                                <button type="button" class="btn btn-default" data-dismiss="modal">{{ 'Close'|trans }}</button>
    +                                                <button type="button" class="btn btn-primary" {{ live_action('revoke:prevent', {'token': token.id}) }}>{{ 'Confirm'|trans }}</button>
    +                                            {% endblock %}
    +                                        {% endcomponent %}
                                         </div>
                                     </p>
                                     <p>
    
  • src/UserBundle/Resources/views/Components/CreateApiToken.html.twig+32 5 modified
    @@ -12,17 +12,44 @@
     
         {% component BootstrapModal with {id: 'create-' ~ attributes.all.id} %}
             {% block modal_header %}
    -            <h5>{{ 'Confirm'|trans }}</h5>
    -            <button type="button" class="close" data-dismiss="modal" aria-label="Close">
    +            <h5>
    +                {% if newPlaintextToken is not empty %}
    +                    {{ 'profile.api.tokens.created.title'|trans }}
    +                {% else %}
    +                    {{ 'Confirm'|trans }}
    +                {% endif %}
    +            </h5>
    +            <button type="button" class="close" data-dismiss="modal" aria-label="Close" {{ live_action('dismissNewToken') }}>
                     <span aria-hidden="true">&times;</span>
                 </button>
             {% endblock %}
             {% block modal_body %}
    -            {{ form_row(form) }}
    +            {% if newPlaintextToken is not empty %}
    +                <div class="alert alert-warning">
    +                    <strong>{{ 'profile.api.tokens.created.warning.title'|trans }}</strong>
    +                    {{ 'profile.api.tokens.created.warning.body'|trans }}
    +                </div>
    +                <div class="input-group" {{ stimulus_controller('clipboard', {'success-content': 'Copied!'}) }}>
    +                    <input readonly="readonly" value="{{ newPlaintextToken }}" class="form-control" type="text" {{ stimulus_target('clipboard', 'source') }} spellcheck="false" />
    +                    <div class="input-group-append">
    +                        <a href="#" class="btn btn-default" title="{{ 'Copy to clipboard'|trans }}" rel="tooltip" {{ stimulus_target('clipboard', 'button') }} {{ stimulus_action('clipboard', 'copy') }}>
    +                            {{ icon('paste') }}
    +                        </a>
    +                    </div>
    +                </div>
    +            {% else %}
    +                {{ form_row(form) }}
    +            {% endif %}
             {% endblock %}
             {% block modal_footer %}
    -            <button type="button" class="btn btn-default" data-dismiss="modal">{{ 'Close'|trans }}</button>
    -            <button type="submit" class="btn btn-primary">{{ 'Confirm'|trans }}</button>
    +            {% if newPlaintextToken is not empty %}
    +                <button type="button" class="btn btn-primary" {{ live_action('dismissNewToken') }}>
    +                    {{ 'profile.api.tokens.created.done'|trans }}
    +                </button>
    +            {% else %}
    +                <button type="button" class="btn btn-default" data-dismiss="modal">{{ 'Close'|trans }}</button>
    +                <button type="submit" class="btn btn-primary">{{ 'Confirm'|trans }}</button>
    +            {% endif %}
             {% endblock %}
         {% endcomponent %}
     
    
  • src/UserBundle/Twig/Components/ApiTokens.php+1 1 modified
    @@ -36,7 +36,7 @@ public function __construct(
         }
     
         /**
    -     * @return array{id: mixed, name: mixed, ip: mixed, token: mixed, lastUsed: mixed}
    +     * @return list<array{id: mixed, name: mixed, ip: mixed, lastUsed: mixed}>
          */
         #[ExposeInTemplate]
         #[LiveListener(CreateApiToken::API_TOKEN_CREATED_EVENT)]
    
  • src/UserBundle/Twig/Components/CreateApiToken.php+23 8 modified
    @@ -11,15 +11,16 @@
     
     namespace SolidInvoice\UserBundle\Twig\Components;
     
    -use Doctrine\ORM\EntityManagerInterface;
     use SolidInvoice\ApiBundle\ApiTokenManager;
     use SolidInvoice\UserBundle\Entity\ApiToken;
    +use SolidInvoice\UserBundle\Entity\User;
     use SolidInvoice\UserBundle\Form\Type\ApiTokenType;
     use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
     use Symfony\Bundle\SecurityBundle\Security;
     use Symfony\Component\Form\FormInterface;
     use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
     use Symfony\UX\LiveComponent\Attribute\LiveAction;
    +use Symfony\UX\LiveComponent\Attribute\LiveProp;
     use Symfony\UX\LiveComponent\ComponentToolsTrait;
     use Symfony\UX\LiveComponent\ComponentWithFormTrait;
     use Symfony\UX\LiveComponent\DefaultActionTrait;
    @@ -33,6 +34,14 @@ final class CreateApiToken extends AbstractController
     
         public const API_TOKEN_CREATED_EVENT = 'api.token.created';
     
    +    /**
    +     * Plaintext of the most recently created token, exposed to the template
    +     * exactly once after a successful create. Cleared as soon as the modal
    +     * is dismissed so it does not persist across re-renders.
    +     */
    +    #[LiveProp(writable: true)]
    +    public ?string $newPlaintextToken = null;
    +
         public function __construct(
             private readonly Security $security,
             private readonly ApiTokenManager $apiTokenManager,
    @@ -45,25 +54,31 @@ protected function instantiateForm(): FormInterface
         }
     
         #[LiveAction]
    -    public function save(EntityManagerInterface $entityManager): void
    +    public function save(): void
         {
             // Submit the form! If validation fails, an exception is thrown
             // and the component is automatically re-rendered with the errors
             $this->submitForm();
     
             /** @var ApiToken $token */
             $token = $this->getForm()->getData();
    -        $token->setUser($this->security->getUser());
    -        $token->setToken($this->apiTokenManager->generateToken());
     
    -        $entityManager->persist($token);
    -        $entityManager->flush();
    +        /** @var User $user */
    +        $user = $this->security->getUser();
    +
    +        $generated = $this->apiTokenManager->create($user, (string) $token->getName());
     
    -        $this->addFlash('success', 'Api Token created');
    +        $this->newPlaintextToken = $generated->plaintext;
     
             $this->emit(self::API_TOKEN_CREATED_EVENT);
    -        $this->dispatchBrowserEvent('modal:close');
     
             $this->resetForm();
         }
    +
    +    #[LiveAction]
    +    public function dismissNewToken(): void
    +    {
    +        $this->newPlaintextToken = null;
    +        $this->dispatchBrowserEvent('modal:close');
    +    }
     }
    
  • UPGRADE.md+13 0 modified
    @@ -1,3 +1,16 @@
    +2.3.17
    +======
    +
    +* API tokens are now stored as HMAC-SHA256 hashes (keyed by `SOLIDINVOICE_APP_SECRET`)
    +  instead of plaintext. The `Version20317` migration re-hashes all existing tokens
    +  in place, so previously issued tokens continue to work without user action.
    +* Existing tokens are no longer recoverable from the database or visible in the UI.
    +  After upgrading, the management page only lists token names; the value itself is
    +  shown exactly once at creation time and must be copied immediately.
    +* Rotating `SOLIDINVOICE_APP_SECRET` now invalidates all API tokens (previously it
    +  only invalidated sessions). After rotating the secret, users must generate new
    +  API tokens.
    +
     2.0.0
     =====
     
    

Vulnerability mechanics

Root cause

"API tokens are stored as plaintext strings in the database without any hashing or one-way transformation."

Attack vector

An attacker who obtains read access to the database — via SQL injection, a leaked backup, a misconfigured replica, or insider access — can run `SELECT token, user_id FROM api_tokens` and immediately obtain all API credentials for every user [ref_id=2]. Each token can then be used directly against the REST API to impersonate any user, exfiltrate invoices, clients, and payment data, and create or delete records indefinitely [ref_id=2]. No further exploitation is required once the database is readable.

Affected code

The vulnerability exists in `src/ApiBundle/ApiTokenManager.php` where `create()` stores the raw token returned by `generateToken()` directly into the database without hashing, and in `src/UserBundle/Repository/ApiTokenRepository.php` where the authentication lookup compares the incoming plaintext token against the stored plaintext value. The patch introduces `src/ApiBundle/Security/ApiTokenHasher.php` to apply HMAC-SHA256 before persistence and modifies the repository to hash the incoming token before the query.

What the fix does

The patch introduces `ApiTokenHasher` which applies HMAC-SHA256 keyed by the application secret (`kernel.secret`) to every token before storage [patch_id=5642282]. `ApiTokenManager::create()` now hashes the plaintext token via `$this->hasher->hash($plaintext)` and persists only the hash. The repository's `getUsernameForToken()` method hashes the incoming plaintext token before the database query, so the lookup compares hash against hash. A new `GeneratedApiToken` value object returns the plaintext to the caller exactly once at creation time; the plaintext is never persisted and is not recoverable from the database afterwards. The UI was also updated to show the plaintext only in a one-time modal after creation and to stop exposing the stored token value in token listings.

Preconditions

  • inputAttacker must have read access to the api_tokens database table (e.g., via SQL injection, leaked backup, misconfigured replica, or insider access).
  • configThe target SolidInvoice installation must have the REST API enabled and at least one API token created.

Generated on Jun 11, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.