Guides
Authentication
Acrobat Sign accepts two kinds of credential: an Integration Key for your own account, and OAuth 2.0 tokens for accounts your users connect. This page helps you choose, then covers each in depth: the authorization-code flow, scopes, refresh, token storage, acting as another user, and what the SDK does to keep secrets out of logs.
Two ways to authenticate
| Integration Key | OAuth 2.0 | |
|---|---|---|
| Whose account | Yours. The key belongs to one user in one account. | Your users'. Each user connects their own Acrobat Sign account. |
| Set-up | Create a key in Acrobat Sign, put it in an environment variable. | Register an API application, implement a redirect and a callback, store tokens. |
| Lifetime | Until revoked. | Access token: short-lived (Adobe says how long in expires_in). Refresh token: 60 days after its last use. |
| Data centre | Discovered with GET /baseUris and cached. | Delivered with the token (api_access_point); no discovery call. |
| SDK entry point | AdobeSign::withIntegrationKey($key) | AdobeSign::withOAuth($oauthClient, $tokenStore, $userKey) |
| Who pays | Your Acrobat Sign plan. | Each connecting user's plan. |
Which one should I use?
- Integration KeyYour company sends contracts from its own Acrobat Sign account: an HR system sending employment contracts, a CRM sending quotes, a back-office job. One credential, no user interaction. This covers most integrations.
- OAuth 2.0You build a product that other organisations use, and each customer sends from their Acrobat Sign account and plan. Also the better choice when your security policy forbids long-lived shared keys, since every token is scoped and expires.
- Key +
asUser()One company account, but agreements must appear as sent by the right employee. An admin's key can act as any user in the account. See acting as another user.
Integration Key
An Integration Key is created in the Acrobat Sign web app (Account → Acrobat Sign API → API Information → Integration Key; see Getting started for the walkthrough). When you create it you choose its scopes; it then acts as the user who created it, with those permissions, until someone revokes it.
use Conceptlz\AdobeSign\AdobeSign;
$sign = AdobeSign::withIntegrationKey((string) getenv('ADOBE_SIGN_INTEGRATION_KEY'));
// The same thing, spelled out:
use Conceptlz\AdobeSign\Auth\IntegrationKeyTokenProvider;
$sign = new AdobeSign(new IntegrationKeyTokenProvider((string) getenv('ADOBE_SIGN_INTEGRATION_KEY')));
The key is sent as a bearer token on every request. An empty or whitespace-only key throws
InvalidArgumentException straight away, so a missing environment variable fails at start-up rather
than at the first API call. A key Adobe rejects throws AuthenticationException (there is nothing to
refresh, so the SDK doesn't retry).
Create the key as a dedicated user
Agreements sent with a key show its creator as the sender, and the key stops working if that person leaves and
their user is deactivated. Many teams create a dedicated Acrobat Sign user (for example
contracts@company.example) and generate the key as that user. Pair it with
asUser() when individual employees should appear as senders.
OAuth 2.0
Acrobat Sign implements the standard OAuth 2.0 authorization-code flow. Your application sends the user to Adobe, the user logs in and approves the permissions you asked for, and Adobe sends them back to your site with a one-time code. Your server exchanges that code for an access token and a refresh token. From then on, the SDK uses the access token and refreshes it when it runs out.
Four classes in Conceptlz\AdobeSign\Auth do the work:
| Class | Job |
|---|---|
OAuthConfig | Your application's client id, secret and redirect URI, plus Adobe's endpoints and the domains a token may come from. |
OAuthClient | The flow itself: build the authorization URL, validate the callback, exchange the code, refresh, revoke. |
TokenStore | An interface you implement to persist each user's AccessToken. InMemoryTokenStore ships for scripts and tests. |
OAuthTokenProvider | Plugs a stored token into AdobeSign and refreshes it when needed. AdobeSign::withOAuth() builds it for you. |
Register an API application
-
Create the application
In Acrobat Sign, open Account → Acrobat Sign API → API Applications and add one. Adobe shows you its Application ID (the OAuth client id) and Client Secret.
-
Configure OAuth
Choose Configure OAuth for Application. Enter your redirect URI exactly as your code will send it (scheme, host, port and path must match), and enable each scope your code will request, with the widest modifier you will need (
self,grouporaccount). -
Store the secret
Keep the client secret on the server, in your secrets manager or environment. It never goes to the browser.
OAuthConfig
new OAuthConfig(string $clientId, string $clientSecret, string $redirectUri, string $authorizationUrl = 'https://secure.adobesign.com/public/oauth/v2', string $tokenHost = 'https://api.adobesign.com', array $allowedDomains = ['adobesign.com', 'echosign.com', 'adobesigngov.us'])
Validates itself on construction: an empty client id or secret, or a redirect URI that is neither HTTPS nor
http://localhost / http://127.0.0.1, throws InvalidArgumentException.
$authorizationUrl is Adobe's shard-neutral login page; Adobe routes each user to their own data centre.
$tokenHost is used for the code exchange only when the callback carries no api_access_point.
$allowedDomains limits where the SDK will send your client secret (see api_access_point).
The authorization-code flow
In code, the flow is two small endpoints. Here it is in plain PHP; any framework's controller looks the same.
use Conceptlz\AdobeSign\Auth\OAuthClient;
use Conceptlz\AdobeSign\Auth\OAuthConfig;
session_start();
$oauth = new OAuthClient(new OAuthConfig(
clientId: (string) getenv('ADOBE_SIGN_CLIENT_ID'),
clientSecret: (string) getenv('ADOBE_SIGN_CLIENT_SECRET'),
redirectUri: 'https://app.example.com/adobe-sign/callback',
));
// A random, unguessable value tying the callback to this browser session (CSRF protection)
$_SESSION['adobe_sign_state'] = OAuthClient::generateState();
header('Location: ' . $oauth->authorizationUrl(
['user_login:self', 'agreement_read:self', 'agreement_write:self', 'webhook_read:self', 'webhook_write:self'],
$_SESSION['adobe_sign_state'],
));
exit;
use Conceptlz\AdobeSign\Exception\OAuthException;
session_start();
$expected = $_SESSION['adobe_sign_state'] ?? '';
unset($_SESSION['adobe_sign_state']); // a state value is single-use
try {
$token = $oauth->handleCallback($_GET, $expected); // checks state, then exchanges the code
$tokenStore->put((string) $currentUser->id, $token);
header('Location: /settings/integrations?connected=1');
} catch (OAuthException $e) {
// access_denied (user clicked "Cancel"), STATE_MISMATCH, MISSING_CODE, or Adobe rejected the code
error_log('Acrobat Sign connect failed: ' . $e->errorCode);
header('Location: /settings/integrations?error=' . urlencode((string) $e->errorCode));
}
exit;
And anywhere afterwards, for that user:
use Conceptlz\AdobeSign\AdobeSign;
$sign = AdobeSign::withOAuth($oauth, $tokenStore, (string) $currentUser->id);
foreach ($sign->agreements()->all(pageSize: 20) as $agreement) {
echo $agreement->status, ' ', $agreement->name, "\n";
}
OAuthClient::generateState(): string
authorizationUrl(array $scopes, string $state): stringGET https://secure.adobesign.com/public/oauth/v2
generateState() returns 64 random hex characters. authorizationUrl() builds the URL with
response_type=code, your client id, redirect URI, space-separated scopes and the state. It throws
InvalidArgumentException for an empty scope list or an empty state: the SDK won't let you skip CSRF protection.
handleCallback(array $query, string $expectedState): AccessToken
exchangeCode(string $code, ?string $apiAccessPoint = null): AccessTokenPOST /oauth/v2/token
handleCallback() takes the callback's query parameters ($_GET or your framework's equivalent) and,
in order: turns an error parameter into OAuthException with Adobe's code (for example
access_denied); compares the state in constant time (STATE_MISMATCH); requires a code
(MISSING_CODE); then calls exchangeCode() on the callback's api_access_point.
Codes are valid for five minutes, so exchange them straight away. Use exchangeCode() directly only if you
validate the callback yourself.
refresh(AccessToken $token): AccessTokenPOST /oauth/v2/refresh revoke(AccessToken $token): voidPOST /oauth/v2/revoke
You rarely call refresh() yourself; OAuthTokenProvider does. A token without a refresh token
throws OAuthException with code NO_REFRESH_TOKEN. revoke() revokes the pair (revoking
either token revokes both); call it when a user disconnects your app, then delete the stored token.
Scopes
A scope is a permission the user grants your app. Each is a name plus an optional modifier: :self (the
user's own agreements, and the default when you leave the modifier out), :group (everything in the user's
group) or :account (the whole account). Group and account modifiers only work when an administrator of that
group or account authorises the app, and your application's OAuth configuration must allow the scope and modifier.
| Scope | Needed for (SDK methods) |
|---|---|
user_login | Signing the user in during the flow; Adobe's own examples always include it. |
agreement_read | agreements()->get, status, all, members, events, documents, combinedDocument, auditTrail |
agreement_write | transientDocuments()->upload, agreements()->create, update, updateState, cancel, sendReminder, signingUrls |
webhook_read, webhook_write | webhooks()->all and get; create, update, delete |
library_read, library_write | libraryDocuments() reads; libraryDocuments()->create and updates |
user_read | users()->me, get, all |
widget_read, widget_write | widgets() (web forms) |
Adobe's spec lists the scopes each endpoint accepts. Some older guides, and the Laravel bridge's defaults, also request
agreement_send; requesting it does no harm if your application allows it. Ask for the least you need:
users see the list on Adobe's consent screen, and a narrow request is easier for their administrators to approve.
Expiry and automatic refresh
OAuthTokenProvider sits between AdobeSign and your TokenStore. Before each request it
reads the user's token and checks its expiry:
- Still valid
If the token expires more than 60 seconds from now (the default leeway), it is used as is.
- About to expire
Otherwise the provider calls
refresh(), writes the new token back withTokenStore::put(), and uses it. - Rejected anyway
If Adobe still answers 401 (a revoked or clock-skewed token), the SDK invalidates it, re-reads the store in case another process already refreshed, refreshes, and retries the request once.
- Refresh impossible
If refreshing fails (refresh token unused for 60 days, revoked, or the user's account changed), you get
OAuthException. The only fix is to send the user through the flow again.
Adobe doesn't rotate refresh tokens: the same refresh token keeps working, and the SDK keeps it unless a response carries
a new one. That means two workers refreshing the same user at the same moment both end up with valid access tokens, so
no locking is needed. If nothing is stored for the key at all, the provider throws OAuthException with code
NO_TOKEN: a good moment to show the user a "Connect Acrobat Sign" button.
use Conceptlz\AdobeSign\AdobeSign;
use Conceptlz\AdobeSign\Auth\OAuthTokenProvider;
// Refresh five minutes before expiry instead of one; pass your PSR-20 clock in tests
$provider = new OAuthTokenProvider($oauth, $tokenStore, key: 'user-42', refreshLeewaySeconds: 300);
$sign = new AdobeSign($provider);
api_access_point and data centres
When Adobe redirects back to you, the query string includes api_access_point, the API host of the user's
data centre (for example https://api.eu1.adobesign.com/). The SDK exchanges the code there, stores it on the
token (AccessToken::$apiAccessPoint), and sends every later call there too, so OAuth clients never call
GET /baseUris. If that address ever stops working (INVALID_API_ACCESS_POINT), the SDK falls back to
discovery once.
Why the access point is validated
The access point arrives in a URL that anyone can craft. If your code sent the code exchange (which includes your client
secret) to whatever host the query string named, an attacker could harvest the secret with a forged callback link. So
OAuthConfig::validateAccessPoint() only accepts HTTPS hosts on adobesign.com,
echosign.com or adobesigngov.us (or the domains you pass as $allowedDomains), and rejects
URLs with embedded credentials. Anything else throws InvalidArgumentException before a request is made.
Storing tokens: TokenStore
TokenStore is a two-method interface. The key is whatever identifies the connection in your application:
a user id, a team id, a tenant id.
interface TokenStore
get(string $key): ?AccessToken
put(string $key, AccessToken $token): void
get() returns null when the user hasn't connected. put() is called after the callback and after every
refresh, and must replace the old token. AccessToken::toArray() gives you
access_token, refresh_token, expires_at (a Unix timestamp) and
api_access_point; AccessToken::fromArray() reverses it.
InMemoryTokenStore keeps tokens for the life of the PHP process, which is right for CLI scripts and tests and
wrong for web apps. A real store persists tokens encrypted: a refresh token is a long-lived credential to
someone else's account. This one uses PDO and libsodium:
use Conceptlz\AdobeSign\Auth\AccessToken;
use Conceptlz\AdobeSign\Auth\TokenStore;
/**
* CREATE TABLE adobe_sign_tokens (owner VARCHAR(191) PRIMARY KEY, payload TEXT NOT NULL);
*/
final class PdoTokenStore implements TokenStore
{
public function __construct(
private readonly \PDO $db,
#[\SensitiveParameter] private readonly string $encryptionKey, // SODIUM_CRYPTO_SECRETBOX_KEYBYTES (32) bytes
) {
}
public function get(string $key): ?AccessToken
{
$statement = $this->db->prepare('SELECT payload FROM adobe_sign_tokens WHERE owner = ?');
$statement->execute([$key]);
$payload = $statement->fetchColumn();
if ($payload === false) {
return null;
}
$raw = (string) base64_decode((string) $payload, true);
$nonce = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$json = sodium_crypto_secretbox_open(substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES), $nonce, $this->encryptionKey);
return $json === false ? null : AccessToken::fromArray(json_decode($json, true, flags: JSON_THROW_ON_ERROR));
}
public function put(string $key, AccessToken $token): void
{
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$box = sodium_crypto_secretbox(json_encode($token->toArray(), JSON_THROW_ON_ERROR), $nonce, $this->encryptionKey);
// REPLACE INTO works on MySQL and SQLite; use INSERT … ON CONFLICT on PostgreSQL
$this->db->prepare('REPLACE INTO adobe_sign_tokens (owner, payload) VALUES (?, ?)')
->execute([$key, base64_encode($nonce . $box)]);
}
}
Returning null when decryption fails means a rotated encryption key simply looks like "not connected" and the user
reconnects, instead of your app crashing. The Laravel bridge
ships an Eloquent store that encrypts with your APP_KEY.
Acting as another user: asUser()
Adobe lets a credential with account-admin rights act on behalf of any user in the same account by adding an
x-api-user header. Agreements are then created as that user (they are the sender, the agreement is in their
list, and replies go to them), and reads see that user's agreements.
asUser(string $user): AdobeSign
Accepts email:jane@example.com, userid:CBJCHBCAABAA…, or a bare email or user id (an
@ means email, anything else a user id). Returns a new client; the original is unchanged,
so it is safe to keep one admin client and derive per-user clients from it. Both share the HTTP pipeline, including the
ETag memory. A blank value throws InvalidArgumentException.
$admin = AdobeSign::withIntegrationKey((string) getenv('ADOBE_SIGN_INTEGRATION_KEY'));
$asOwner = $admin->asUser($deal->ownerEmail); // x-api-user: email:owner@company.example
$agreementId = $asOwner->agreements()->create($agreement);
// Later, from the same admin client, as somebody else
$admin->asUser('userid:CBJCHBCAABAAexample')->agreements()->cancel($otherId, 'Superseded');
If the credential isn't allowed to act for that user, or the user doesn't exist in the account, Adobe answers with codes
such as INVALID_X_API_USER_HEADER or INVALID_ON_BEHALF_OF_USER; the SDK maps both to
AuthenticationException. With OAuth, the token needs :account (or :group) scopes granted by an
administrator for this to work.
Writing your own TokenProvider
Both built-in providers implement TokenProvider, and you can too, for example to read the key from a secrets
manager that rotates it.
use Conceptlz\AdobeSign\AdobeSign;
use Conceptlz\AdobeSign\Auth\TokenProvider;
final class VaultTokenProvider implements TokenProvider
{
private ?string $token = null;
public function __construct(private readonly SecretsClient $vault)
{
}
public function getAccessToken(): string
{
return $this->token ??= $this->vault->read('adobe-sign/integration-key');
}
// Called after a 401: forget the cached value so the next call reads the rotated key
public function invalidate(): void
{
$this->token = null;
}
// Stable and NOT secret: keys the cached data-centre lookup
public function cacheKey(): string
{
return 'vault_main_account';
}
}
$sign = new AdobeSign(new VaultTokenProvider($vault));
After a 401, the SDK calls invalidate(), asks for the token again and retries the request once, but only if the
token actually changed. A provider whose token already knows its data centre can also implement
ProvidesApiAccessPoint (one method, apiAccessPoint(): ?string) to skip discovery, as
OAuthTokenProvider does.
How the SDK protects credentials
Credentials leak most often through logs, error trackers and debug dumps. The SDK closes each of those paths:
#[\SensitiveParameter]On every parameter that carries a secret: the Integration Key (withIntegrationKey(),IntegrationKeyTokenProvider), the client secret (OAuthConfig), the authorization code (exchangeCode()), both tokens inAccessToken, and participant passwords. PHP replaces them with a placeholder in stack traces.- Masked dumps
AccessToken,OAuthConfigandIntegrationKeyTokenProviderdefine__debugInfo(), sovar_dump()and most dumpers show***. - Redacted exceptions
ApiExceptionkeeps the failed request for debugging, with theAuthorizationheader replaced byBearer ***, whoever constructs it. OAuth errors keep the request without its body, which held the secret, code or refresh token. - Quiet loggingThe PSR-3 logger receives method, path, status and timing. Never headers, never bodies.
- Hashed cache keysThe data-centre cache is keyed by a SHA-256 prefix of the key or of your token-store key, so the cache never holds a credential.
- Checked redirectsState compared in constant time, redirect URIs must be HTTPS (or loopback for development), and secrets only go to allow-listed Adobe hosts.
What the SDK can't do for you
It can't stop your own code from logging $token->toArray(), which contains both tokens in clear text, or from
committing a key to Git. Store tokens encrypted, give keys the narrowest scopes that work, and revoke credentials when
people leave or a user disconnects.
Where next
Common questions
Should I use an Integration Key or OAuth?
An Integration Key if your application sends from your own organisation's Acrobat Sign account; OAuth if each of your customers connects their own account. If you're unsure, start with a key: the rest of your code doesn't change when you switch, only the line that builds the client.
Users get OAuthException after a couple of months. Why?
Adobe expires a refresh token that hasn't been used for 60 days. Users who don't trigger any API call for that long must
connect again. Catch OAuthException where you build the client and show a reconnect prompt. If you need the
connection to stay alive, make a cheap call (such as users()->me()) for each connected user from a scheduled
job every few weeks.
Can I test OAuth on localhost?
Yes. OAuthConfig accepts http://localhost and http://127.0.0.1 redirect URIs; everything
else must be HTTPS. Register the exact URI in your application's OAuth configuration. The package's
examples/oauth-server.php runs the whole flow on php -S localhost:8080.
Why do I get STATE_MISMATCH on the callback?
The state in the callback doesn't match the one in the session. The usual causes: the session cookie didn't survive the
round trip (a different domain or SameSite=Strict), the user started the flow twice in two tabs, or the session
expired while they were on Adobe's page. Keep the session cookie SameSite=Lax on the callback's domain.
Can one application hold tokens for many Acrobat Sign accounts?
Yes. One TokenStore holds any number of tokens under different keys, and each
AdobeSign::withOAuth(..., $key) client works with one of them. Tokens on different data centres are fine: each
token carries its own access point.
How do I rotate an Integration Key without downtime?
Create the new key, deploy it, then revoke the old one. Keys don't expire on their own, so there is no deadline. If your
deployment reads the key from a secrets manager at runtime, a custom TokenProvider whose
invalidate() re-reads it picks up the new key on the first 401, as shown above.
asUser() fails with an authentication error. What's missing?
Acting for another user needs an account administrator's credential, and the target must be an active user in the same
account. With OAuth, the admin must also have granted :account (or :group) scopes. Check the
errorCode on the exception: INVALID_X_API_USER_HEADER points at the header value,
INVALID_ON_BEHALF_OF_USER at the user.