Guides
Token storage & Doctrine
OAuth gives your app an access token and a refresh token for each connected user. They are as sensitive as a password and must outlive deploys and cache clears. This page explains how the bundle encrypts them, the two stores it ships (a cache pool and a Doctrine table), how to rotate the encryption key, and how to plug in your own store.
Choosing a store
token_store: cache (default) | token_store: doctrine | Your own service id | |
|---|---|---|---|
| Class | CacheTokenStore | DoctrineDbalTokenStore | Anything implementing TokenStore |
| Lives in | A PSR-6 pool (oauth.token_cache_pool) | A table (adobe_sign_tokens) on a DBAL connection | Wherever you decide |
| Needs | A persistent, shared pool | doctrine/dbal; a migration or the create command | Your code |
Survives cache:pool:clear | No | Yes | Up to you |
| In backups | Only if the pool is backed up | With your database | Up to you |
| Encrypted | Yes | Yes | Only if you use TokenEncryptor |
| Good for | Prototypes; apps with a durable Redis | Most production apps | Existing secret stores, per-tenant databases |
If you already run Doctrine, choose doctrine: tokens then live next to the users they belong to, are
backed up with them, and nobody clears them by accident while debugging a cache problem.
Encryption
Both shipped stores pass tokens through Conceptlz\AdobeSign\Symfony\TokenStore\TokenEncryptor before
they are written. It uses libsodium's crypto_secretbox (XSalsa20-Poly1305), which encrypts
and authenticates:
- Key. A 32-byte key derived with
sodium_crypto_generichashfrom a domain-separation label plusoauth.encryption_key, which defaults to%kernel.secret%(yourAPP_SECRET). The label means the derived key differs from anything else that uses the same secret. - Format.
v1:followed by base64 of a fresh 24-byte random nonce and the ciphertext. The same token encrypts differently every time. - Contents. The access token, refresh token, expiry and the account's API access point, as JSON.
- Failure mode. A value encrypted with another key, tampered with, truncated or not produced by the encryptor decrypts to
null. The store then reports "no token",isConnected()returns false, and the user reconnects. Nothing throws, and nothing half-valid is used. - No leaks in dumps. The secret is a
#[\SensitiveParameter]and__debugInfo()masks it, sodump(),print_r()and stack traces don't print it.
An empty key (for example APP_SECRET unset) throws MisconfiguredException
("adobe_sign.oauth.encryption_key is empty; set it or kernel.secret (APP_SECRET).") the first time a token is
encrypted or decrypted.
Rotating APP_SECRET, and a dedicated key
Because the default key derives from APP_SECRET, rotating APP_SECRET makes every stored
token unreadable. Nothing crashes: each user simply appears disconnected and must click "Connect" again. That
may be acceptable after a security incident; it is annoying as routine hygiene. To rotate
APP_SECRET independently, give the tokens their own key:
adobe_sign:
oauth:
encryption_key: '%env(ADOBE_SIGN_TOKEN_KEY)%'
php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;' # generate a value
php bin/console secrets:set ADOBE_SIGN_TOKEN_KEY --env=prod # store it in the vault
Switching to a dedicated key is itself a rotation
Tokens encrypted under APP_SECRET can't be read with the new key. Either make the switch before users connect, or re-encrypt once: read every token with an encryptor built from the old secret and put() it back through the store configured with the new one. The bundle has no automatic dual-key mode.
use Conceptlz\AdobeSign\Symfony\TokenStore\TokenEncryptor;
$old = new TokenEncryptor($oldAppSecret);
$new = new TokenEncryptor($newTokenKey);
foreach ($connection->fetchAllAssociative('SELECT user_key, payload FROM adobe_sign_tokens') as $row) {
$token = $old->decrypt($row['payload']);
if ($token !== null) {
$connection->update('adobe_sign_tokens', ['payload' => $new->encrypt($token)], ['user_key' => $row['user_key']]);
}
}
The cache-pool store
CacheTokenStore is the default because it needs no schema. Each token is one cache item:
- Hashed keys. The item key is
adobe_sign.token.plus the SHA-256 of the user key. User keys are often emails, and PSR-6 forbids@and a few other characters in keys; hashing also keeps emails out of cache key listings. - No expiry set by the store. Items are saved with
expiresAfter(null), which means "the pool's default lifetime". A pool withoutdefault_lifetimekeeps them forever; a pool with one would silently drop tokens after that time. - Encrypted values. Anyone who can read the pool sees only
v1:…strings.
Use a persistent, shared pool
The default token_cache_pool is cache.app, a filesystem pool under var/cache on
each machine. Tokens there disappear when that directory is wiped (a fresh container, some deploy strategies,
cache:pool:clear cache.app) and aren't visible to your other servers or workers on other machines.
For anything beyond a single-server prototype, give tokens their own durable pool:
framework:
cache:
pools:
adobe_sign.tokens:
adapter: cache.adapter.redis
provider: '%env(REDIS_URL)%'
# no default_lifetime: tokens must not expire from the cache
adobe_sign:
oauth:
token_store: cache
token_cache_pool: adobe_sign.tokens
Never an array or APCu pool
An array pool lives for one request (and is reset by kernel.reset); APCu is per server and per PHP-FPM pool. Tokens stored there vanish, and users appear disconnected moments after connecting. For Redis, also check the server's maxmemory-policy: an allkeys-* eviction policy can evict tokens under memory pressure. Use noeviction or volatile-* for the Redis instance that holds tokens.
The Doctrine DBAL store
composer require doctrine/dbal # already there with symfony/orm-pack
adobe_sign:
oauth:
token_store: doctrine
doctrine:
connection: default # service doctrine.dbal.default_connection
table: adobe_sign_tokens
DoctrineDbalTokenStore keeps one row per user key. put() updates the row and inserts it if
there was none; if two requests insert at the same moment, the loser's unique-constraint violation is caught
and turned into an update, so concurrent token refreshes never fail. The store is registered under the
service id adobe_sign.token_store.doctrine and also aliased to its class, so you can autowire
DoctrineDbalTokenStore directly.
The table
| Column | DBAL type | Content |
|---|---|---|
user_key | string, length 191, primary key | The key from userKey(): the user identifier by default. 191 characters keeps the index within MySQL's limit for utf8mb4. |
payload | text | The encrypted token (v1:…). |
updated_at | datetime_immutable | When the token was last stored (connect or refresh). Handy for spotting stale connections. |
With Doctrine ORM: migrations pick it up
When doctrine/orm is installed and token_store is doctrine, the bundle registers
DoctrineSchemaListener on Doctrine's postGenerateSchema event. Whenever the ORM computes the
schema it wants, the listener adds the token table (for the configured connection only, and only if it isn't
there already). So the usual workflow just works:
php bin/console doctrine:migrations:diff # the migration includes CREATE TABLE adobe_sign_tokens
php bin/console doctrine:migrations:migrate
The same listener keeps doctrine:schema:validate and doctrine:schema:update from wanting to
drop the table. If you use doctrine.dbal.schema_filter to hide tables from the ORM, make sure it still
lets adobe_sign_tokens through.
Without the ORM: create-token-table
With DBAL alone there is no schema diff, so the bundle offers a command (registered only when
token_store is doctrine):
$ php bin/console adobe-sign:create-token-table
[OK] Created table "adobe_sign_tokens".
$ php bin/console adobe-sign:create-token-table
[OK] Table "adobe_sign_tokens" already exists.
It is idempotent, so it can run on every deploy. It never alters an existing table. If you manage schema with migrations but without the ORM, write the migration by hand with the same columns:
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
final class Version20261104120000 extends AbstractMigration
{
public function up(Schema $schema): void
{
$table = $schema->createTable('adobe_sign_tokens');
$table->addColumn('user_key', Types::STRING, ['length' => 191]);
$table->addColumn('payload', Types::TEXT);
$table->addColumn('updated_at', Types::DATETIME_IMMUTABLE);
$table->setPrimaryKey(['user_key']);
}
public function down(Schema $schema): void
{
$schema->dropTable('adobe_sign_tokens');
}
}
A separate connection
To keep secrets in a different database, define a second DBAL connection and name it:
oauth.doctrine.connection: secrets makes the store use doctrine.dbal.secrets_connection.
The schema listener only adds the table to the schema of that connection, so a diff for the default entity
manager won't include it; run the create command or a migration against the secrets connection.
Forgetting a token
Both shipped stores have forget(string $key): void, which deletes the stored token. It is not part of
the core TokenStore interface and does not revoke the token at Adobe; see
Disconnecting for the full pattern. When a user account is deleted in your
app, forget their token in the same place you delete their other data.
Writing your own TokenStore
Implement Conceptlz\AdobeSign\Auth\TokenStore, which has two methods, and register the class as a
service. Reuse the bundle's encryptor (autowirable by its class name) unless your storage encrypts on its own:
namespace App\AdobeSign;
use App\Repository\UserRepository;
use Conceptlz\AdobeSign\Auth\AccessToken;
use Conceptlz\AdobeSign\Auth\TokenStore;
use Conceptlz\AdobeSign\Symfony\TokenStore\TokenEncryptor;
use Doctrine\ORM\EntityManagerInterface;
/** Keeps each user's token in a column on the users table. */
final class UserColumnTokenStore implements TokenStore
{
public function __construct(
private readonly UserRepository $users,
private readonly EntityManagerInterface $em,
private readonly TokenEncryptor $encryptor,
) {
}
public function get(string $key): ?AccessToken
{
$sealed = $this->users->findOneBy(['email' => $key])?->getAdobeSignToken();
return $sealed !== null ? $this->encryptor->decrypt($sealed) : null;
}
public function put(string $key, AccessToken $token): void
{
$user = $this->users->findOneBy(['email' => $key]) ?? throw new \RuntimeException("No user $key.");
$user->setAdobeSignToken($this->encryptor->encrypt($token));
$this->em->flush();
}
}
adobe_sign:
oauth:
token_store: App\AdobeSign\UserColumnTokenStore
Rules a store must follow, because the core relies on them:
get()returns null for "nothing usable". Missing, undecryptable or corrupt all mean null; the core then reportsNO_TOKENand the user reconnects.put()overwrites. It is called on connect and after every refresh, possibly from two processes at once. Last write wins is correct; both values are valid.- It is called from workers. No request, no session, no signed-in user. Key off the string you are given.
- It survives
kernel.reset. Don't cache tokens in a property for longer than a request or message, or implementResetInterfaceto clear it.
The adobe-sign:create-token-table command and the schema listener only exist for
token_store: doctrine; your own store manages its own schema.
Common questions
Can I switch from the cache store to Doctrine later?
Yes, but stored tokens don't move by themselves. Either let users reconnect, or copy them once: for each known user key, get() from a CacheTokenStore on the old pool and put() into the DoctrineDbalTokenStore. Both use the same encryptor, so the key stays the same.
How do I list every connected user?
With the Doctrine store, SELECT user_key, updated_at FROM adobe_sign_tokens. The cache store can't list (keys are hashed), which is one more reason to prefer Doctrine in production.
Is kernel.secret a good enough encryption key?
Cryptographically yes, if it is long and random (at least 32 random characters; recent Symfony skeletons leave APP_SECRET empty in .env, so set it per environment). The reason to use a dedicated encryption_key is operational: rotating APP_SECRET then doesn't disconnect your users.
Our staging database is a copy of production. Are tokens exposed there?
Not if staging has a different APP_SECRET or encryption_key: the copied payloads won't decrypt and every user appears disconnected. If staging shares the production key, it can act as your users at Adobe; give it its own key, or empty the table when you copy.
What personal data does the token table hold?
The user key (by default the user identifier, often an email) in plain text, and the encrypted token. Treat the row as personal data: delete it with forget() when you delete the user, and use a key resolver that returns an opaque id if you'd rather not store emails.