API Platform security

Edit on GitHub

This document explains how authentication and authorization work in the API Platform integration and how to secure your API resources.

Overview

Spryker’s API Platform security is built on Symfony’s SecurityBundle and provides the following:

  • Authentication: Bearer token (JWT) validation using Spryker’s OAuth infrastructure.
  • Authorization: Security expressions on resources and operations using Symfony’s is_granted() function.
  • Role mapping: OAuth scopes from JWT tokens are automatically mapped to Symfony roles.

For setup instructions, see Integrate API Platform security.

How authentication works

When a request includes an Authorization: Bearer <token> header, the following flow is executed:

  1. The OauthAuthenticator extracts the Bearer token from the request header.
  2. The token is validated locally using Spryker’s OAuth client infrastructure — no Zed call is required.
  3. JWT claims (user ID, scopes, client ID) are extracted from the validated token.
  4. An ApiUser object is created with the extracted claims and made available through Symfony’s security system.

If no Authorization header is present, the request proceeds as unauthenticated. Resources that require authentication must enforce it using security expressions.

Resolving the user behind a token

For tokens issued to Back Office and merchant users, the Backend API also resolves the user record behind the token and makes it the acting user of the request. Business rules that depend on the current user then apply as they do in the Back Office and the Merchant Portal, and Persistent ACL scopes merchant users to their merchant the way the Merchant Portal does; Back Office users are not scoped. This applies to API Platform resources only; the legacy Glue infrastructure does not establish an acting user and exposes the token data as GlueRequestTransfer.requestUser instead.

By default, the user is looked up by the id_user claim of the token. Only active users qualify: a token of a deactivated or deleted user, or one that resolves to no single user, is rejected with 401 and the error code 003 before the resource is reached.

Tokens issued by a third-party identity provider may not carry id_user. To resolve the user from other claims, implement \Spryker\Shared\UserExtension\Dependency\Plugin\UserIdentityCriteriaExpanderPluginInterface and register the plugin in UserDependencyProvider::getUserIdentityCriteriaExpanderPlugins() of the Glue layer. The plugin receives the decoded claims and maps them onto the user criteria; the default id_user lookup applies only when no plugin adds an identifying condition.

<?php

namespace Pyz\Glue\User\Plugin\User;

use Generated\Shared\Transfer\UserCriteriaTransfer;
use Spryker\Shared\UserExtension\Dependency\Plugin\UserIdentityCriteriaExpanderPluginInterface;

class SubjectClaimUserIdentityCriteriaExpanderPlugin implements UserIdentityCriteriaExpanderPluginInterface
{
    public function expand(array $identityClaims, UserCriteriaTransfer $userCriteriaTransfer): UserCriteriaTransfer
    {
        if (!isset($identityClaims['sub'])) {
            return $userCriteriaTransfer;
        }

        $userCriteriaTransfer->getUserConditionsOrFail()->addUsername($identityClaims['sub']);

        return $userCriteriaTransfer;
    }
}

Public by default

The default security configuration grants PUBLIC_ACCESS to all paths. This means all endpoints are publicly accessible unless a resource explicitly defines a security expression. This approach lets you selectively protect resources rather than maintaining a global allowlist.

Security expressions

Security expressions are the primary mechanism for protecting API resources. They use Symfony’s ExpressionLanguage and are evaluated at different stages of request processing.

Resource-level security

Apply security to all operations of a resource:

resource:
  name: Customers
  shortName: customers
  security: "is_granted('ROLE_USER')"

  operations:
    - type: Get
    - type: Patch
    - type: Delete

All operations on this resource require the user to have ROLE_USER.

Operation-level security

Apply security to specific operations while keeping others public:

resource:
  name: Customers
  shortName: customers

  operations:
    - type: Post
      # No security — registration is public

    - type: Get
      security: "is_granted('ROLE_USER')"

    - type: Patch
      security: "is_granted('ROLE_USER')"

    - type: Delete
      security: "is_granted('ROLE_USER')"

Operation-level security overrides resource-level security for that specific operation.

Declare security on the resource

Bearer token validation runs only for resources that declare a security expression at the resource level. If only the operations carry expressions, an anonymous request skips authentication and is answered with 403 instead of 401. When the operations of a resource need different roles, declare the union at the resource level and narrow it per operation:

resource:
  name: MerchantProfiles
  shortName: merchant-profiles
  security: "is_granted('ROLE_MERCHANT_USER') or is_granted('ROLE_BACK_OFFICE_USER')"

  operations:
    - type: Get
      uriTemplate: '/merchant-profile'
      security: "is_granted('ROLE_MERCHANT_USER')"
    - type: Get
      uriTemplate: '/merchant-profiles/{merchantReference}'
      security: "is_granted('ROLE_BACK_OFFICE_USER')"

Post-denormalize security

Evaluated after the request body has been deserialized into the resource object. This lets you check authorization based on the submitted data:

resource:
  name: Orders
  shortName: orders
  securityPostDenormalize: "is_granted('EDIT', object)"

The object variable refers to the deserialized resource instance.

Post-validation security

Evaluated after validation has passed. Use this when authorization depends on validated data:

resource:
  name: Payments
  shortName: payments
  securityPostValidation: "is_granted('PROCESS', object)"

Expression variables

The following variables are available in security expressions:

Variable Description
user The authenticated ApiUser object, or null if unauthenticated
object The resource object (available in securityPostDenormalize and securityPostValidation)
request The current Symfony Request object

Common expression patterns

# Require any authenticated user
security: "is_granted('ROLE_USER')"

# Require a specific role
security: "is_granted('ROLE_ADMIN')"

# Allow authenticated users OR public access
security: "is_granted('PUBLIC_ACCESS') or is_granted('ROLE_USER')"

Spryker-specific security keys

In addition to the standard security, securityPostDenormalize, and securityPostValidation expressions, resource schemas support the following Spryker-specific keys that control how denials are reported:

Key Purpose
securityMessage Custom message returned when the security expression denies access.
securityCode Glue-compatible numeric error code returned with 403 Forbidden when an authenticated user is denied.
securityGetStatusCode For GET requests, the status code to return instead of 403—typically 404. The response is rewritten to the provider’s not-found error so the API does not reveal whether the resource exists.
securityBearerAuthRequired Marks the resource as requiring Bearer authentication. Unauthenticated requests receive the standard 403 Missing access token. response.
securityAnonymousAuthRequired Appends or request.headers.has('X-Anonymous-Customer-Unique-Id') to the security expression at generation time, letting guest customers through.
securityPostDenormalizeMessage, securityPostValidationMessage Custom messages for the corresponding expressions.

Example from the Customers resource:

resource:
  name: Customers
  shortName: customers
  security: "is_granted('ROLE_CUSTOMER')"
  securityCode: '411'
  securityGetStatusCode: 404
  securityBearerAuthRequired: true

Roles and OAuth scope mapping

When a JWT token is validated, OAuth scopes are automatically mapped to Symfony roles using the following convention:

OAuth Scope Symfony Role
read ROLE_READ
write ROLE_WRITE
admin ROLE_ADMIN
customer ROLE_CUSTOMER
back-office-user ROLE_BACK_OFFICE_USER
merchant-user ROLE_MERCHANT_USER
{custom_scope} ROLE_{CUSTOM_SCOPE}

The scope name is uppercased and hyphens become underscores, so the roles match the ones the Back Office and the Merchant Portal use. The user scope, which every Back Office and merchant user token carries, is not mapped to a role; instead, every authenticated caller holds ROLE_USER. Use ROLE_BACK_OFFICE_USER or ROLE_MERCHANT_USER to distinguish the two audiences. For how the tokens are obtained, see Authenticate as a Back Office user and Authenticate as a merchant user.

All authenticated users automatically receive ROLE_USER in addition to their scope-based roles.

The mapping rule is: the scope name is uppercased and prefixed with ROLE_.

Accessing the authenticated user

In providers and processors, you can access the authenticated user through Symfony’s Security service.

In a provider

use Spryker\ApiPlatform\State\Provider\AbstractStorefrontProvider;
use Symfony\Bundle\SecurityBundle\Security;

class CustomersStorefrontProvider extends AbstractStorefrontProvider
{
    public function __construct(
        protected Security $security,
    ) {
    }

    protected function provideItem(): ?object
    {
        $user = $this->security->getUser();

        if ($user === null) {
            return null;
        }

        // $user is an instance of ApiUser
        $userId = $user->getUserIdentifier();

        // Access OAuth metadata
        $oauthClientId = $user->getOauthClientId();

        // Fetch and return the customer data using the user ID
    }
}

In a processor

use Spryker\ApiPlatform\State\Processor\AbstractStorefrontProcessor;
use Symfony\Bundle\SecurityBundle\Security;

class CustomersStorefrontProcessor extends AbstractStorefrontProcessor
{
    public function __construct(
        protected Security $security,
    ) {
    }

    protected function processPatch(mixed $data): mixed
    {
        $user = $this->security->getUser();

        // Use the authenticated user context for business logic
    }
}

ApiUser properties

The ApiUser object provides the following methods:

Method Return Type Description
getUserIdentifier() string The user ID extracted from the JWT token
getRoles() array All roles including ROLE_USER and scope-mapped roles
getOauthClientId() string The OAuth client ID from the token
getOauthAccessTokenId() string The OAuth access token ID

Error responses

Error responses keep the Glue-compatible JSON:API format. The exact response depends on why access was denied:

  • Missing token on a protected resource: the GlueAuthenticationEntryPoint returns 403 Forbidden with the standard Glue error:

    {
        "errors": [
            {
                "code": "002",
                "status": 403,
                "detail": "Missing access token."
            }
        ]
    }
    
  • Authenticated user denied by a security expression: the API returns 403 Forbidden with the resource’s configured securityCode and securityMessage.

  • GET requests on resources with securityGetStatusCode: instead of 403, the response is rewritten to the configured status—typically 404 with the provider’s not-found error—so the API does not reveal whether a resource exists for someone else’s account.

  • Resources that do not require Bearer tokens (securityBearerAuthRequired not set, for example agent endpoints): an unauthenticated denial returns 401 with the resource’s configured error code.

Next steps