WooCommerce Dual API

Authentication and authorization

Authentication and authorization in the dual API revolve around a security principal: a per-request object representing who is calling. Authentication produces the principal; authorization decides what that principal may do, expressed through attributes.

The principal

Each request resolves to exactly one principal, produced once by a PrincipalResolver. The default resolver wraps the current WordPress user:

final class PrincipalResolver {
    public function resolve_principal(): Principal {
        return new Principal( wp_get_current_user() );
    }
}

The default Principal carries the WP_User and exposes:

Plugins authenticating against something else (app token, signed webhook, …) ship their own PrincipalResolver and principal class. The resolver’s return type declares the plugin’s principal type, which ApiBuilder uses to type-check authorize()/$_principal signatures at build time. A resolver may take an optional \WP_REST_Request $request parameter, or none. To reject bad credentials, throw UnauthorizedException or InvalidTokenException from the resolver. See Creating a dual API in a plugin and Infrastructure classes.

Refusing requests up front

Endpoints are registered without a REST-level permission check, because authorization is decided per operation and operations can be public. That means every request, anonymous ones included, is parsed, cached and validated before any authorize() runs. Sites whose APIs have no public operations, and sites that want to apply their own admission rules, can refuse requests right after the principal has been resolved and before anything else happens:

The gate fails closed like the other ones: the filter must return strictly true to admit the request, and a throw from the principal’s is_authenticated() or from a filter callback refuses it. The filter is not invoked when principal resolution itself failed; that case is answered with the resolver’s own error.

Authorization attributes

Authorization is declarative. Two attributes are built in:

#[RequiredCapability( 'manage_woocommerce' )]
class ListAttendees { /* ... */ }

An attribute is recognized as an authorization attribute by convention: it declares a public authorize() method returning bool. The first non-underscore parameter receives the principal:

public function authorize( MyPrincipal $principal ): bool { /* ... */ }
// or, for unconditional access:
public function authorize(): bool { return true; }

Plugins define their own authorization attributes the same way. The reference plugin’s #[RequiresScope( 'events:read' )] checks a scope on its EventsPrincipal:

#[Attribute( Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY )]
final class RequiresScope {
    public function __construct( public readonly string $scope ) {}

    public function authorize( EventsPrincipal $principal ): bool {
        return $principal->has_scope( $this->scope );
    }
}

See the Attributes reference. This is the recommended approach; it keeps authorization separate from business logic.

The authorize() method on commands

For logic that doesn’t fit an attribute, a query/mutation class can declare its own authorize() method. Compose it with the attribute decision via the bool $_preauthorized parameter (which will receive true if the attribute gates already grant). CancelEvent in the reference plugin lets managers through via its #[RequiresScope( 'events:edit' )] and widens that to the event’s own organizer:

public function authorize( int $id, bool $_preauthorized, EventsPrincipal $_principal ): bool {
    if ( $_preauthorized ) {
        return true;
    }
    $event = Store::get_event( $id );
    return null !== $event && $event->organizer_login === $_principal->user_login;
}

Granular (type- and field-level) authorization

Authorization attributes apply at four levels:

Target Effect
Query / mutation (class) Gates the whole operation.
Output type (class) AND-composed into every field gate of that type (including via a trait the type uses).
Output field (property) Gates that field; re-evaluated per item when the field is a list.
Input field (property) Gates the field, but only when it was actually provided in the request.

#[PublicAccess] on a property is a no-op (it always grants) and produces a build warning.

authorize() methods can opt into three more context parameters, supplied per call site, detected by name, in any order:

#[Attribute( Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY )]
final class OwnerOrScope {
    public function __construct( public readonly string $scope ) {}

    public function authorize( EventsPrincipal $principal, mixed $_parent ): bool {
        return $principal->has_scope( $this->scope )
            || ( is_object( $_parent ) && $_parent->organizer_login === $principal->user_login );
    }
}

Deny shape and HTTP status

When a gate denies:

The error code and HTTP status depend on whether the principal is authenticated:

Credential problems surfaced by the resolver use UNAUTHORIZED (401) or INVALID_TOKEN (401). See Exceptions.

Introspection, debug mode, and metadata gating

Three sensitive surfaces are gated independently, each by a combination of a principal method, a filter, and a fail-closed default:

Surface Principal method Filter Default if method absent
Native introspection (__schema, __type) can_introspect() woocommerce_graphql_can_introspect deny
Debug mode (also requires _debug=1) can_use_debug_mode() woocommerce_graphql_can_use_debug_mode deny
_apiMetadata discovery can_query_metadata(), else falls back to can_introspect() woocommerce_graphql_can_query_metadata deny

All three gates fail closed:

When introspection is denied, validation errors also lose the Did you mean "..."? suggestions the engine otherwise appends when a field, type, argument, input field or enum value name is one typo away from an existing one; with introspection blocked, those suggestions would let a caller enumerate the schema by trial and error. Errors raised by resolvers keep their wording.

The filters receive ( bool $decision, ?object $principal, \WP_REST_Request $request ). They are not invoked when principal resolution itself failed. They are also site-wide: a callback affects every dual-API endpoint on the site, so branch on the $request route if it should apply to only one; see Scope: what applies where. The default Principal declares can_introspect() (gated on manage_woocommerce), which also governs _apiMetadata since it has no can_query_metadata() - so admin access to both works out of the box, and other principals are denied unless they opt in. (The reference plugin’s EventsPrincipal grants can_introspect() to its manager role only.)

Example override:

add_filter(
    'woocommerce_graphql_can_introspect',
    fn( bool $can, $principal, \WP_REST_Request $request ): bool =>
        $can || 'true' === $request->get_param( 'x-allow-introspection' ),
    10,
    3
);

Pre-authorization for code-API callers

Code that calls the code API directly (not through GraphQL) can ask whether the attribute gates would grant access for a principal, without executing the command, via ResolverHelpers::compute_preauthorized( string $command_fqcn, object $principal ): bool.