Guides
OAuth: connect user accounts
When your users have their own Acrobat Sign accounts, OAuth lets them connect those accounts to your app so agreements go out in their name. The package provides the two routes, stores the tokens encrypted and refreshes them for you; you add a "Connect" button and call AdobeSign::forUser($user).
When to use OAuth
Use OAuth when the agreements should belong to your users' Acrobat Sign accounts: they appear in their Acrobat Sign dashboard, emails come from them, and their own plan and settings apply. A sales tool, an HR product or a property platform where each customer already pays for Acrobat Sign are typical cases. If everything is sent from one account you own, an Integration Key is simpler; see Integration Key or OAuth?
Set up the Adobe application
-
Create an API application
In Acrobat Sign go to Account → Acrobat Sign API → API Applications and create one. Note its client id (application id) and client secret.
-
Configure OAuth on it
Add your callback as a redirect URI, exactly as your app will send it, for example
https://app.example.com/adobe-sign/oauth/callback. Enable the scopes you will request, with the modifier (self,grouporaccount) you need. Adobe refuses requests for scopes the application doesn't allow. -
Add the credentials to .env
ADOBE_SIGN_CLIENT_ID=CBJCHBCAABAA... ADOBE_SIGN_CLIENT_SECRET=your-client-secret # Only if the URL differs from route('adobe-sign.oauth.callback'): # ADOBE_SIGN_REDIRECT_URI=https://app.example.com/adobe-sign/oauth/callback -
Create the token table
php artisan vendor:publish --tag=adobe-sign-migrations php artisan migrate
The routes
The package registers two GET routes when oauth.routes is true (the default):
| Name | URI | Does |
|---|---|---|
adobe-sign.oauth.redirect | /adobe-sign/oauth/redirect | Stores a fresh random state in the session and redirects to Adobe's consent page with your client id, redirect URI and scopes. |
adobe-sign.oauth.callback | /adobe-sign/oauth/callback | Checks the state, exchanges the code for tokens, stores them under the user's key, fires OAuthConnected and redirects to oauth.redirect_after. |
Three config keys shape them:
oauth.route_prefix(defaultadobe-sign/oauth) changes the URIs. If you change it, update the redirect URI on the Adobe application too.oauth.route_middleware(default['web', 'auth']).webis required because the state lives in the session; an authentication middleware is required because tokens are stored under the signed-in user. Without a user the callback aborts with 401.oauth.redirect_after(default/) is where the user lands after connecting. Point it at your integrations or settings page.
Add the button wherever users manage integrations:
@if (AdobeSign::isConnected(auth()->user()))
<p>Acrobat Sign is connected.</p>
<form method="POST" action="{{ route('integrations.acrobat-sign.disconnect') }}">
@csrf @method('DELETE')
<button>Disconnect</button>
</form>
@else
<a href="{{ route('adobe-sign.oauth.redirect') }}">Connect Acrobat Sign</a>
@endif
The global AdobeSign alias is the facade, so it works in Blade without an import.
The connect flow, step by step
-
Redirect with a fresh state
OAuthController::redirect()generates 64 random hex characters withOAuthClient::generateState(), stores them in the session underadobe_sign_oauth_state, and redirects tohttps://secure.adobesign.com/public/oauth/v2withresponse_type=code, your client id, redirect URI, scopes and the state. -
The user consents at Adobe
They sign in to Acrobat Sign and see the permissions your scopes ask for. Adobe then redirects to your callback with a one-time
code(valid for five minutes), thestate, andapi_access_point: the data centre their account lives on. -
Check the state, once
The callback pulls the state from the session, so it can be used only once, and compares it in constant time with the one in the URL. A mismatch, a replayed callback URL or a missing code ends with 400. This is the CSRF protection of the flow: a link someone else started can't attach their Acrobat Sign account to your user.
-
Exchange the code on the right data centre
The core validates that
api_access_pointis an Adobe host before sending anything to it (a forged value gets a 400 and your client secret never leaves the app), then posts the code and secret to{api_access_point}oauth/v2/token. -
Store, announce, redirect
The tokens are stored through the bound
TokenStoreunderAdobeSign::userKey($user),OAuthConnectedis dispatched, and the user is redirected tooauth.redirect_after.
What the callback answers when something goes wrong
| Status | When | What the user should do |
|---|---|---|
| 400 | State mismatch or missing (expired session, replayed URL, second tab), no code, or an api_access_point outside Adobe's domains | Start again from the Connect button |
| 403 | The user denied access at Adobe, or Adobe rejected the code (e.g. expired, or the redirect URI doesn't match) | Connect again; if it repeats, check the Adobe application's settings |
| 502 | Acrobat Sign could not be reached during the exchange | Try again shortly |
| 401 | Nobody is signed in (only possible if you removed the auth middleware) | Sign in first |
These are plain abort() responses, so your app's error pages render them. To show friendlier messages, render custom errors/400.blade.php and errors/403.blade.php views, or disable the routes and write your own controller around AdobeSign::oauth().
Where tokens live
The default TokenStore is Conceptlz\AdobeSign\Laravel\EloquentTokenStore, which
reads and writes the adobe_sign_tokens table through the
Conceptlz\AdobeSign\Laravel\Models\AdobeSignToken model. One row per user key:
| Column | Type | Holds |
|---|---|---|
id | big integer, primary key | Row id |
key | string, unique | The user key from userKey(), e.g. 42 or team:7 |
access_token | text | Access token, encrypted with APP_KEY |
refresh_token | text, nullable | Refresh token, encrypted with APP_KEY |
expires_at | timestamp, nullable | When the access token expires |
api_access_point | string, nullable | The account's API host, e.g. https://api.eu1.adobesign.com/ |
created_at, updated_at | timestamps | Standard Eloquent timestamps |
The model casts both tokens with Laravel's encrypted cast and hides them from
toArray() and JSON, so a stray dd($token) or API resource won't expose them.
A database dump alone is useless without APP_KEY.
Rotating APP_KEY
Encrypted tokens can only be read with the key that wrote them. If you rotate APP_KEY, list the old key in APP_PREVIOUS_KEYS so existing rows still decrypt; tokens are re-encrypted with the new key the next time they refresh. Without that, every user has to connect again.
Using your own token store
Bind any implementation of Conceptlz\AdobeSign\Auth\TokenStore (two methods,
get() and put()) in a service provider and the package uses it instead,
for the routes and for forUser() alike:
use Conceptlz\AdobeSign\Auth\{AccessToken, TokenStore};
final class TeamSettingsTokenStore implements TokenStore
{
public function get(string $key): ?AccessToken
{
$json = TeamSetting::where('key', "adobe-sign:$key")->value('encrypted_value');
return $json === null ? null : AccessToken::fromArray(json_decode(decrypt($json), true));
}
public function put(string $key, AccessToken $token): void
{
TeamSetting::updateOrCreate(
['key' => "adobe-sign:$key"],
['encrypted_value' => encrypt(json_encode($token->toArray()))],
);
}
}
// AppServiceProvider::register()
$this->app->singleton(TokenStore::class, TeamSettingsTokenStore::class);
The OAuthConnected event
Conceptlz\AdobeSign\Laravel\Events\OAuthConnected fires once per successful connection,
after the tokens are stored. It has three read-only properties:
| Property | Type | Meaning |
|---|---|---|
$user | Illuminate\Contracts\Auth\Authenticatable | The signed-in user who connected |
$key | string | The token store key, from userKey() |
$apiAccessPoint | ?string | The account's API host, e.g. https://api.eu1.adobesign.com/ |
A listener is the place to record the connection on your side, for example which Acrobat Sign user connected:
use Conceptlz\AdobeSign\Laravel\AdobeSignManager;
use Conceptlz\AdobeSign\Laravel\Events\OAuthConnected;
final class RecordAcrobatSignConnection
{
public function __construct(private readonly AdobeSignManager $adobe)
{
}
public function handle(OAuthConnected $event): void
{
$me = $this->adobe->forUser($event->key)->users()->me();
$event->user->forceFill([
'adobe_sign_email' => $me->getEmail(),
'adobe_sign_connected_at' => now(),
])->save();
}
}
This listener runs synchronously during the callback, which is fine for one quick call. If it does more, make it ShouldQueue and rely on $event->key rather than the user object, since the event doesn't use SerializesModels.
Acting as a connected user
use Conceptlz\AdobeSign\Laravel\Facades\AdobeSign;
if (! AdobeSign::isConnected($user)) {
return redirect()->route('adobe-sign.oauth.redirect');
}
$agreementId = AdobeSign::forUser($user)->agreements()->create($info);
forUser()accepts a user model or the key itself (42,'team:7'). In queued jobs pass the key, since there is no signed-in user there.isConnected()only checks that a row exists. It doesn't call Adobe.ADOBE_SIGN_AUTH=oauthmakes the facade's forwarded calls and the injectedAdobeSignuseforUser(auth()->user())automatically, which is convenient when every request is a signed-in user acting for themselves.- Data centres come from the token's
api_access_point, so users onna1andeu1work side by side without extra configuration.
One connection per team
By default the key is the user's auth identifier. If a whole team shares one Acrobat Sign account,
change the mapping once, in a service provider's boot():
use Conceptlz\AdobeSign\Laravel\AdobeSignManager;
AdobeSignManager::resolveUserKeyUsing(fn ($user) => 'team:'.$user->current_team_id);
Now the callback stores tokens under team:7, and forUser($anyTeamMember) uses
them. The resolver is static and applies to the whole process; set it at boot rather than per request.
When you pass a key directly, pass the resolved form ('team:7').
Refreshing tokens
Access tokens last about an hour. You never refresh them yourself: the core's
OAuthTokenProvider refreshes a token 60 seconds before it expires, or once after the API
rejects it with 401, and writes the new token back to the store. Adobe doesn't rotate refresh tokens,
so two processes refreshing the same user at once both end up valid, with no locking.
What can't be fixed automatically is a dead refresh token: Adobe expires it after 60 days without use,
and it dies when the user revokes access or the account changes. The next call then throws
Conceptlz\AdobeSign\Exception\OAuthException, with an $errorCode such as
INVALID_TOKEN or EXPIRED_TOKEN (or NO_TOKEN when nothing is stored).
The only cure is to connect again:
use Conceptlz\AdobeSign\Exception\OAuthException;
try {
$id = AdobeSign::forUser($user)->agreements()->create($info);
} catch (OAuthException $e) {
report($e); // the message never contains the token or secret
return redirect()->route('adobe-sign.oauth.redirect')
->with('status', 'Your Acrobat Sign connection has expired. Please connect again.');
}
To keep rarely used connections alive, a scheduled command can make a cheap call such as
users()->me() for each connected user every few weeks; any successful refresh restarts
the 60-day clock.
Disconnecting
Disconnecting has two halves: revoke the token at Adobe, so it stops working there, and delete it from
your table. EloquentTokenStore::forget() does the second; it isn't part of the
TokenStore interface, so check the type if you might swap stores.
use Conceptlz\AdobeSign\Exception\AdobeSignException;
use Conceptlz\AdobeSign\Laravel\AdobeSignManager;
use Conceptlz\AdobeSign\Laravel\EloquentTokenStore;
use Illuminate\Http\Request;
final class DisconnectAcrobatSignController
{
public function __invoke(Request $request, AdobeSignManager $adobe)
{
$key = $adobe->userKey($request->user());
$tokens = $adobe->tokens();
if ($token = $tokens->get($key)) {
try {
$adobe->oauth()->revoke($token); // revoking either token revokes the pair
} catch (AdobeSignException $e) {
report($e); // still remove it locally
}
}
if ($tokens instanceof EloquentTokenStore) {
$tokens->forget($key);
}
return back()->with('status', 'Acrobat Sign disconnected.');
}
}
Webhooks created with that user's token keep running at Adobe until you delete them; do that first with forUser($user)->webhooks()->delete($id) if you track their ids.
Choosing scopes
Scopes limit what a token can do. Each has a modifier: :self (the user's own resources,
the default), :group (their group's, for group admins) or :account (the whole
account, for account admins). The package requests these by default:
user_login:self agreement_read:self agreement_write:self agreement_send:self webhook_read:self webhook_write:self
That covers sending, tracking and downloading your user's own agreements and registering webhooks for them. Add a scope only for a feature that needs it, for example library templates or web forms, and enable it on the Adobe application first:
ADOBE_SIGN_SCOPES="user_login:self agreement_read:self agreement_write:self agreement_send:self webhook_read:self webhook_write:self library_read:self"
Scopes are fixed when the user consents. After you change them, existing users keep their old grant
until they connect again, so prompt them to reconnect for the new feature. Asking for
:account scopes only works for account admins, and Adobe shows the user what they grant;
narrow scopes make the consent screen less alarming.
Common questions
Adobe says the redirect URI is invalid. What should I check?
The URI your app sends must match one registered on the Adobe application character for character, including https, host, port and path. By default it is route('adobe-sign.oauth.callback'), built from APP_URL or the request; behind a proxy that terminates TLS, configure trusted proxies so it comes out as https://, or set ADOBE_SIGN_REDIRECT_URI explicitly.
Why does the callback fail with 400 for some users?
Usually the session that stored the state is gone: the session expired during a slow sign-in, the user started in one tab and finished in another after starting again, or the session cookie wasn't sent back (a SameSite=strict cookie on the redirect from Adobe, or a different domain). Laravel's default lax cookie works. More in Troubleshooting.
Can I use OAuth for some users and an Integration Key for the rest?
Yes. Keep ADOBE_SIGN_AUTH=integration_key so the default client uses the key, and call forUser($user) for users who connected. Both kinds of client share the same config, cache and logger.
How safe are the stored tokens?
They are encrypted with APP_KEY by Laravel's encrypted cast, hidden from serialisation, and redacted from SDK exceptions. Someone with both your database and APP_KEY could use them until they are revoked, so protect APP_KEY like any other secret.
Do webhooks created through OAuth need extra configuration?
No. Adobe sends your application's client id in X-AdobeSign-ClientId, and the webhook allow-list falls back to ADOBE_SIGN_CLIENT_ID when ADOBE_SIGN_WEBHOOK_CLIENT_IDS is empty.
Can I write my own connect controller?
Yes. Set oauth.routes to false, then use AdobeSign::oauth()->authorizationUrl($scopes, $state) and AdobeSign::oauth()->handleCallback($request->query(), $expectedState), and store the result with AdobeSign::tokens()->put($key, $token). The core's authentication page documents those methods.