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.
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:
is_authenticated(): bool: true when user->ID > 0. Anonymous requests are not signalled by null; they’re a real principal whose user has ID 0.can_introspect(): bool: defaults to true only when the user has the manage_woocommerce capability.can_use_debug_mode(): bool: defaults to true only when the user has the manage_options capability.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.
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:
is_authenticated() returns false gets a 401 UNAUTHORIZED response with the message Authentication required., and the query is never parsed. This also makes #[PublicAccess] operations unreachable, so leave it on for storefront-style APIs. A principal class that doesn’t declare is_authenticated() can’t be told apart from an authenticated one and is admitted, the same convention the authorization errors follow.woocommerce_graphql_request_allowed filter, ( bool $allowed, object $principal, \WP_REST_Request $request ), applied to that decision. Use it for rate limiting, IP rules, per-route policies, or to admit specific anonymous requests while the setting is off. It is site-wide, so branch on the request’s route when it should apply to one endpoint only.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 is declarative. Two attributes are built in:
#[PublicAccess]: no authentication required (authorize() always returns true).#[RequiredCapability( 'capability-name' )]: requires the principal to hold a WordPress capability. Repeatable; multiple capabilities are ANDed (so all the capabilities are required in the user for authorization to succeed).#[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.
authorize() method on commandsFor 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;
}
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:
array $_metadata: #[Metadata] entries visible at the call site, in up to three slices: ['query'] (originating operation), ['type'] (enclosing type), ['field'] (the gated field).array $_args: the GraphQL arguments at the call site.mixed $_parent: the enclosing object being resolved (for an output-field gate, the parent object; lets you implement owner-or-scope checks).#[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 );
}
}
When a gate denies:
extensions.subject = { type, field, attribute } alongside the preserved extensions.code.The error code and HTTP status depend on whether the principal is authenticated:
is_authenticated() returns false) → UNAUTHORIZED / 401 (authenticating might help).is_authenticated() → FORBIDDEN / 403 (authenticating won’t help).Credential problems surfaced by the resolver use UNAUTHORIZED (401) or INVALID_TOKEN (401). See Exceptions.
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:
null/unresolved principal denies.=== true (a truthy non-bool denies).true to grant; loose values like 1 or 'yes' deny.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
);
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.