Skip to content

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 setting used_at when 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_at is null) and is never consumed (used_at stays null); instead, last_used_at is 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:

$hash = hash('sha256', $plaintext);

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:

SystemConnectionToken::query()->access()->where('token', hash('sha256', $incoming))->first();

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.

SystemConnectionToken::query()->handshake()->get();

access()

Restricts the query to purpose = access tokens.

SystemConnectionToken::query()->access()->get();

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.

$token->isExpired(); // bool

isUsed()

Returns true when used_at is not null. Always false for access tokens, since they are never consumed.

$token->isUsed(); // bool

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.

$token->isValid(); // bool — equivalent to !isExpired() && !isUsed()

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:

docker compose exec php php artisan connection-tokens:prune

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}/status
  • GET /api/widget/configuration
  • PATCH /api/widget/configuration
  • GET /api/widget/sync-batches
  • POST /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-connections outright — read-only, and exactly what a token straight out of the bootstrap flow is proven to need (see ConnectionTokenAuthenticationE2eTest).
  • POST /api/system-connections/connect, only when the request body carries no connection_token — the initiate half, which creates a pending connection in the caller's own tenant. The same route's finalize half (a connection_token present, claiming an arbitrary plugin-initiated pending connection into the caller's tenant) is always rejected for this scope, since ConnectSystemConnectionController dispatches 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-widget token is minted and re-broadcast to a third party's embedding page
  • ADR-0038 — why the connector-widget token's access is scoped via an allow-list middleware