System Connection Tokens¶
A SystemConnectionToken is a bearer token that authenticates or finalizes a SystemConnection, one row per token, in the single system_connection_tokens table. Its purpose (App\Enums\TokenPurpose) decides which of the two it does:
handshake— a short-lived, single-use token that lets an e-commerce plugin or a consuming platform finalize a connection from either side of the connection flow. It carries a one-hour TTL and is consumed by settingused_atwhen the connection is finalized.access— a long-lived, reusable token that authenticates ongoing API and widget calls once a connection is active. It never expires (expires_atisnull) and is never consumed (used_atstaysnull); instead,last_used_atis stamped on every successful authentication.
Both purposes are minted by App\Services\ConnectionTokenIssuerService (issueHandshakeToken() and issueAccessToken()) and share the same plaintext-once, hash-forever handling: Conflux returns the plaintext value once to the caller and thereafter stores only its SHA-256 hash.
issueAccessToken() takes an optional ?AccessTokenScope $scope, and derives name from it when $name is not separately given ($name ?? $scope?->value) — so a caller passing scope: AccessTokenScope::ConnectorWidget gets both columns set consistently from one typed argument, and the two can never silently drift apart:
$issuer->issueAccessToken($connection, scope: AccessTokenScope::ConnectorWidget);
// name: 'connector-widget', scope: AccessTokenScope::ConnectorWidget
$issuer->issueAccessToken($connection, name: 'examples-configuration-widget');
// name: 'examples-configuration-widget', scope: null — a label with no authorization meaning
Model attributes¶
| Attribute | Type | Notes |
|---|---|---|
id |
UUID | Primary key via HasUuids |
system_connection_id |
UUID (FK) | Owning SystemConnection; FK uses restrictOnDelete |
purpose |
string (enum) | App\Enums\TokenPurpose: handshake or access; governs the lifetime, reuse, and name/last_used_at semantics below |
name |
string, nullable | Free-text issuer label for access tokens, with no enforced shape — null for handshake tokens. Historically doubled as the authorization signal (plugin-widget, connector-widget); admin-preview is a legacy value, no longer issued, that may still appear on older tokens |
scope |
string (enum), nullable | App\Enums\AccessTokenScope: ConnectorWidget or PluginWidget, null for anything else (handshake tokens, the examples-widget preview token, or an access token issued with no scope). This, not name, is what route-authorization middlewares read — see Route-level scope |
token |
string | SHA-256 hash of the plaintext token; unique; hidden from serialization |
expires_at |
datetime, nullable | Handshake tokens: one hour out. Access tokens: null — they never expire |
used_at |
datetime, nullable | Handshake tokens: set when the token is consumed. Access tokens: stays null — they are reusable, not consumed |
last_used_at |
datetime, nullable | Access tokens: stamped on every successful authentication, by both SystemConnectionTokenGuard and AuthenticateWidgetToken. null for handshake tokens, which never authenticate an ongoing request |
deleted_at |
timestamp | Soft deletes enabled |
Token hashing¶
Conflux never stores the plaintext token. When a token is issued the plaintext is returned to the caller once and immediately discarded. Only the SHA-256 hash is persisted:
Lookups must hash the incoming value before querying, and should scope to the purpose that applies — SystemConnectionTokenGuard and AuthenticateWidgetToken both add ->access(), since only access-purpose tokens authenticate ongoing requests:
Casts¶
| Attribute | Cast |
|---|---|
purpose |
TokenPurpose::class |
scope |
AccessTokenScope::class |
expires_at |
datetime |
used_at |
datetime |
last_used_at |
datetime |
Scopes¶
handshake()¶
Restricts the query to purpose = handshake tokens.
access()¶
Restricts the query to purpose = access tokens.
Helper methods¶
isExpired()¶
Returns true when expires_at is set and in the past. A null expiry — the case for every access token — means the token never expires, so this always returns false.
isUsed()¶
Returns true when used_at is not null. Always false for access tokens, since they are never consumed.
isValid()¶
Returns true when the token is neither expired nor used. This is the single validity check to use before authenticating or consuming a token, and applies to both purposes.
Pruning¶
Once a handshake token is expired or consumed it serves no further purpose. The connection-tokens:prune Artisan command hard-deletes every handshake token (->handshake()) where expires_at is in the past or used_at is set, leaving still-valid handshake tokens untouched, and logs the number of records removed. Access tokens are never pruned by this command: they carry no expiry and are never consumed, so they stay valid for the connection's lifetime.
It is scheduled to run daily (registered in routes/console.php) and can also be run manually:
The related abandoned-connections:prune command also inspects handshake tokens — a pending SystemConnection is only eligible for cleanup once it has no remaining unused, unexpired handshake token to complete its handshake with. See System Connections — Cleanup.
Route-level scope¶
Every access token authenticates against both guards it is eligible for by default, regardless of scope — AuthenticateWidgetToken resolves any valid token to its SystemConnection for the widget route group, and SystemConnectionTokenGuard (the connection guard) does the same everywhere else, neither consulting scope by itself. Two middlewares, one per guard, are where scope is actually read — both restrict only AccessTokenScope::ConnectorWidget; every other scope (PluginWidget, or null) passes through unaffected:
EnsureWidgetTokenIsAuthorizedForRoute, chained immediately after AuthenticateWidgetToken in routes/api.php (which also binds the resolved token record itself onto the request as widget_connection_token, alongside the SystemConnection as widget_connection), allow-lists:
GET /api/widget/connections/{id}/statusGET /api/widget/configurationPATCH /api/widget/configurationGET /api/widget/sync-batchesPOST /api/widget/external-credentials
EnsureConnectionTokenIsAuthorizedForRoute is the connection-guard counterpart. Unlike the widget middleware, it isn't attached to individual routes: it's appended to the whole api middleware group, right after auth:connection (bootstrap/app.php), so a route added anywhere in routes/api.php later inherits the check automatically — the same closed-by-default property the widget middleware gets from sitting on the whole widget group. SystemConnectionTokenGuard::resolvedToken() exposes the token record behind a resolved connection so this middleware can read it without a second query. It authorizes:
GET /api/me,GET /api/systems,GET /api/system-connectionsoutright — read-only, and exactly what a token straight out of the bootstrap flow is proven to need (seeConnectionTokenAuthenticationE2eTest).POST /api/system-connections/connect, only when the request body carries noconnection_token— the initiate half, which creates a pending connection in the caller's own tenant. The same route's finalize half (aconnection_tokenpresent, claiming an arbitrary plugin-initiated pending connection into the caller's tenant) is always rejected for this scope, sinceConnectSystemConnectionControllerdispatches between the two based on body content, not a separate route.
POST /api/system-connections/{connection}/access-tokens is closed to this scope entirely — minting a fresh access token for another connection in the tenant has no legitimate connector-widget use, and would otherwise let it escalate to a sibling connection's widget surface. It isn't on any allow-list, so being part of the api group is enough to close it.
Because the group-wide middleware would otherwise also run against /system-connections/disconnect, /uninstall, and the whole widget group (whose bearer token the connection guard can resolve just as validly as AuthenticateWidgetToken does, since both query the same table) and reject them outright, each strips it via withoutMiddleware() — the same way each already strips auth:connection itself. POST /api/system-connections/register and webhooks/{connection} strip it too, since neither authenticates via this guard at all. Every allow-list here is closed, not deny-based: a route added to routes/api.php later is unreachable by the connector-widget scope by default until someone explicitly extends it — but a route that's exempt from the check entirely must still remember to strip the middleware, the same way it already has to remember to strip auth:connection. See ADR-0038 for the full reasoning, including why scope is a separate column rather than a cast on name, why this started out attached per-route instead of to the group, and which related points were deliberately left out of scope.
Neither check applies to scope: null/PluginWidget tokens anywhere — they keep the same unrestricted access they always had.
Deactivation and uninstall¶
Routine pruning (above) only ever touches tokens that are already expired or used; a token belonging to a healthy connection is left alone regardless of age. Deactivating a SystemConnection (setting status = Inactive) or uninstalling it (status = Uninstalled) is different: SystemConnectionService::revokeAllTokens(), called from the Deactivate and Uninstall transitions, hard-deletes every SystemConnectionToken for that connection immediately, valid or not, bypassing SoftDeletes, the same precedent set by connection-tokens:prune.
This means a handshake token that was issued moments before deactivation, and would otherwise still be valid, is gone along with everything else — there is no grace period and no soft-delete for tokens on deactivation or uninstall. See System Connections — Deactivation and Uninstall for the full set of side effects.
Disconnecting (status = Disconnected) is the exception: tokens are deliberately kept for the 30-day grace period so a reconnect needs no new credentials. See System Connections — Disconnect.
Factory states¶
SystemConnectionTokenFactory defaults to a handshake-purpose token and ships five named states for use in tests:
| State | Effect |
|---|---|
expired() |
Sets expires_at to one hour in the past |
used() |
Sets used_at to now() |
valid() |
Sets expires_at one hour ahead and used_at to null (default behaviour made explicit) |
handshakeToken() |
Sets purpose to TokenPurpose::HANDSHAKE (redundant with the factory default; useful for readability) |
accessToken() |
Sets purpose to TokenPurpose::ACCESS |
SystemConnectionToken::factory()->valid()->create();
SystemConnectionToken::factory()->expired()->create();
SystemConnectionToken::factory()->used()->create();
SystemConnectionToken::factory()->accessToken()->create();
SystemConnectionToken::factory()->handshakeToken()->expired()->create();
Source files¶
| File | Purpose |
|---|---|
app/Models/SystemConnectionToken.php |
Eloquent model, casts, scopes (handshake, access), isExpired, isUsed, isValid |
app/Models/Concerns/BelongsToSystemConnection.php |
Shared systemConnection(): BelongsTo concern |
app/Enums/TokenPurpose.php |
handshake / access backed enum |
app/Enums/AccessTokenScope.php |
ConnectorWidget / PluginWidget backed enum — read by both route-authorization middlewares |
app/Services/ConnectionTokenIssuerService.php |
Mints handshake and access tokens; the only place that knows the plaintext format and the hash function; derives name from scope when not given separately |
database/migrations/2026_06_26_092454_create_system_connection_tokens_table.php |
Original table schema (handshake tokens only) |
database/migrations/2026_07_16_165105_add_purpose_column_to_system_connection_tokens_table.php |
Adds the purpose column |
database/migrations/2026_08_07_100000_add_access_token_columns_to_system_connection_tokens_table.php |
Adds name and last_used_at; makes expires_at nullable |
database/migrations/2026_08_07_100100_migrate_system_connection_access_tokens_into_system_connection_tokens.php |
Backfills rows from the former system_connection_access_tokens table |
database/migrations/2026_10_02_000000_add_scope_to_system_connection_tokens_table.php |
Adds the scope column, nullable, no backfill |
database/factories/SystemConnectionTokenFactory.php |
Factory with expired, used, valid, handshakeToken, accessToken states |
app/Console/Commands/PruneConnectionTokens.php |
connection-tokens:prune command — deletes expired or used handshake tokens |
routes/console.php |
Daily schedule entries for connection-tokens:prune and abandoned-connections:prune |
App\Services\SystemConnectionService::revokeAllTokens() |
Hard-deletes every token for a connection on deactivation or uninstall |
app/States/SystemConnection/Transitions/Deactivate.php, Uninstall.php |
Call revokeAllTokens() when a connection moves to Inactive or Uninstalled |
app/Http/Middleware/AuthenticateWidgetToken.php |
Resolves an access token for the widget route group; binds the SystemConnection as widget_connection and the token record itself as widget_connection_token |
app/Http/Middleware/EnsureWidgetTokenIsAuthorizedForRoute.php |
Reads widget_connection_token.scope and restricts ConnectorWidget to the widget-group allow-list above; every other scope is unaffected |
app/Auth/SystemConnectionTokenGuard.php |
The connection guard; resolvedToken() exposes the token record behind a resolved connection |
app/Http/Middleware/EnsureConnectionTokenIsAuthorizedForRoute.php |
Reads the connection guard's resolved token scope; allow-lists /me, /systems, /system-connections and /connect's initiate half for ConnectorWidget, closing /access-tokens and everything else by default |
bootstrap/app.php |
Appends EnsureConnectionTokenIsAuthorizedForRoute to the api middleware group, right after auth:connection |
routes/api.php |
Chains AuthenticateWidgetToken → EnsureWidgetTokenIsAuthorizedForRoute → EnsureWidgetConnectionIsActive on the widget route group; strips EnsureConnectionTokenIsAuthorizedForRoute from the widget group, /disconnect, /uninstall, /register and webhooks/{connection} |
See also¶
- System Connections — the parent model that a token authorizes, and its Deactivation section
- Connection Flow — how tokens are issued and consumed across both registration flows
- Connection Widget —
SystemConnectionAccessToken, the widget's own long-lived token type - Connector Widget — where the
connector-widgettoken is minted and re-broadcast to a third party's embedding page - ADR-0038 — why the
connector-widgettoken's access is scoped via an allow-list middleware