Verified on Shopware 6.7

Shopware 6 assumes users authenticate through its own login flow. That assumption breaks the moment your storefront runs inside a native mobile app where authentication happens natively - Face ID, biometrics, token exchange with a backend - and the webview just needs to pick up an already-authenticated session.

There is no built-in Shopware mechanism for this. What you need to build is a fabricated session layer: a custom API endpoint that accepts the app's user identity, maps it to a Shopware customer, establishes a session, and hands a token back to the app. Every subsequent webview request carries that token as a cookie, and a kernel subscriber validates it on every frontend hit.


What the App Side Does (Theory)

The native app handles authentication entirely on its own. Shopware plays no role in verifying credentials, managing biometrics, or controlling the login screen.

Once the app has established who the user is, it calls a single Shopware endpoint - once per session - to exchange the app's user identity for a Shopware context token. That token gets injected into the webview's cookie store before the storefront URL is loaded. From that point on, the webview behaves like any authenticated Shopware session: the customer is logged in, sees their account, prices, and cart without any login interaction.

The app is also responsible for:

  • Deciding when the session needs to be refreshed (token expiry, re-authentication)
  • Clearing the cookie on logout
  • Passing any user identity fields the Shopware endpoint needs (user ID, name, email, company, language)

Shopware does not need to know any of this. It just needs to receive a valid token on each request.


The Session Initialization Endpoint

The entry point is a custom API controller. The app calls it once with the user's identity data and receives a Shopware context token in return.

#[Route('/api/app/customer', name: 'api.app.customer', methods: ['POST'])]
public function initializeSession(Request $request, Context $context): JsonResponse
{
    $required = ['userId', 'email', 'firstName', 'lastName', 'companyId', 'languageCode'];
    foreach ($required as $field) {
        if (!$request->request->get($field)) {
            return new JsonResponse(['error' => "Missing required field: {$field}"], 400);
        }
    }

    $token = $this->customerService->upsertCustomer($request, $context);

    return new JsonResponse(['sw-context-token' => $token]);
}

The controller does nothing but validate the payload and delegate. All the logic lives in CustomerService.


Customer Upsert and Login

The service maps the app's user identity to a Shopware customer, creates or updates it, then logs it in to produce a context token.

public function upsertCustomer(Request $request, Context $context): string
{
    $customerData = $this->mapCustomerData($request, $context);
    $this->customerRepository->upsert([$customerData], $context);
    return $this->loginCustomerById($customerData['id']);
}

private function mapCustomerData(Request $request, Context $context): array
{
    return [
        'id'             => $this->externalUserIdToShopwareId($request->request->get('userId')),
        'customerNumber' => $request->request->get('userId'),
        'groupId'        => $this->resolveCustomerGroupId($request->request->get('companyId')),
        'salesChannelId' => $this->config->get('AcmePlugin.config.salesChannelId'),
        'languageId'     => $this->resolveLanguageId($request->request->get('languageCode'), $context),
        'firstName'      => $request->request->get('firstName'),
        'lastName'       => $request->request->get('lastName'),
        'email'          => $request->request->get('email'),
        'accountType'    => 'private',
    ];
}

public function loginCustomerById(string $customerId): string
{
    $salesChannelContext = $this->salesChannelService->createContextForCustomer($customerId);
    return $this->accountService->loginById($customerId, $salesChannelContext);
}

externalUserIdToShopwareId - the app's user ID is deterministically converted to a valid Shopware UUID. MD5 is the right tool here: it produces a 32-character hex string, which is exactly the format Shopware uses for its IDs internally. Pass the external user ID through md5() and you have a valid, stable Shopware customer ID with no additional formatting needed. The same input always produces the same output, which means upsert either creates or updates cleanly without any prior lookup. At user-base scale, MD5 collision risk is not a real concern - and unlike UUID v4, it is reversible in the sense that the mapping is consistent and predictable, which makes debugging straightforward.

loginById - Shopware's AccountService::loginById creates a row in sales_channel_api_context and returns the generated token. That token is the Shopware session. It is what the app injects into the webview cookie.


The Token and the sales_channel_api_context Table

Shopware's sales_channel_api_context table is the bridge between the external session and the Shopware session. Each row maps a context token to a customer ID (plus sales channel and other context data).

When loginById runs, it writes to this table. The token it returns is the value that ends up in the webview cookie.

Two lookups are useful from this table:

// Given a token, find the customer
public function getCustomerIdByToken(string $token): ?string
{
    $result = $this->connection->createQueryBuilder()
        ->select('LOWER(HEX(customer_id)) AS customerId')
        ->from('sales_channel_api_context')
        ->where('token = :token')
        ->setParameter('token', $token)
        ->executeQuery()
        ->fetchAssociative();

    return $result['customerId'] ?? null;
}

// Given a customer, find their current token
public function getTokenByCustomerId(string $customerId): ?string
{
    $result = $this->connection->createQueryBuilder()
        ->select('token')
        ->from('sales_channel_api_context')
        ->where('customer_id = :id')
        ->setParameter('id', Uuid::fromHexToBytes($customerId))
        ->executeQuery()
        ->fetchAssociative();

    return $result['token'] ?? null;
}

These are direct DBAL queries rather than DAL repository calls because sales_channel_api_context is not a full entity with a repository - querying it raw is the correct approach.


The Request Validator Subscriber

Every frontend request needs to be validated against the token the app injected. This is a KernelEvents::RESPONSE subscriber - it fires after Shopware processes the request, before the response is sent.

class AppSessionHandlerSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [KernelEvents::RESPONSE => 'onKernelResponse'];
    }

    public function onKernelResponse(ResponseEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        $route = $event->getRequest()->attributes->get('_route', '');
        if (!str_starts_with($route, 'frontend.')) {
            return;
        }

        $cookieToken = $event->getRequest()->cookies->get('sw-context-token');
        if (!$cookieToken) {
            $this->sendUnauthorized('No session token. Initialize via /api/app/customer.');
        }

        $customerId = $this->customerService->getCustomerIdByToken($cookieToken);
        if (!$customerId) {
            $this->sendUnauthorized('Token not recognized. Re-initialize the session.');
        }

        $sessionToken = $event->getRequest()->getSession()->get('sw-context-token');
        if ($sessionToken !== $cookieToken) {
            // Token exists in DB but not in the current PHP session - re-establish
            $this->customerService->loginCustomerById($customerId);
            $response = new RedirectResponse($event->getRequest()->getUri());
            $response->send();
            exit;
        }
    }

    private function sendUnauthorized(string $message): void
    {
        $response = new JsonResponse(['error' => $message], 401);
        $response->send();
        exit;
    }
}

Why RESPONSE and not REQUEST? By the time KernelEvents::REQUEST fires, Shopware's session and context resolution has not fully run yet. Using RESPONSE means the full request has been processed - we can inspect the session data Shopware resolved and compare it against the cookie. Catching it at response time also means the customer gets a clear error rather than a partial render.

The mismatch branch handles a real edge case: the token exists in sales_channel_api_context (so the customer is known and the session was initialized at some point), but the PHP session no longer matches. This happens after a server restart, session expiry, or when the same user opens the app on a new device. Re-calling loginById re-establishes the PHP session and a redirect replays the original request cleanly.


Maintenance Mode

If the sales channel is in maintenance mode, the webview should receive a clear signal rather than a broken storefront:

$salesChannel = $this->salesChannelService->getDefaultSalesChannel();
if ($salesChannel->isMaintenance()) {
    $clientIp = $event->getRequest()->getClientIp();
    if (!in_array($clientIp, $salesChannel->getMaintenanceIpWhitelist(), true)) {
        $response = new JsonResponse(['error' => 'Under maintenance. Try again later.'], 503);
        $response->send();
        exit;
    }
}

Run this check before token validation so maintenance mode takes priority over session state.


Services Registration

<service id="Acme\AppSession\Controller\AppCustomerController" public="true">
    <argument type="service" id="Acme\AppSession\Service\CustomerService"/>
    <tag name="controller.service_arguments"/>
</service>

<service id="Acme\AppSession\Service\CustomerService">
    <argument type="service" id="customer.repository"/>
    <argument type="service" id="Shopware\Core\Checkout\Customer\SalesChannel\AccountService"/>
    <argument type="service" id="Acme\Resources\Service\SalesChannelService"/>
    <argument type="service" id="Doctrine\DBAL\Connection"/>
    <argument type="service" id="Shopware\Core\System\SystemConfig\SystemConfigService"/>
</service>

<service id="Acme\AppSession\Subscriber\AppSessionHandlerSubscriber">
    <argument type="service" id="Acme\AppSession\Service\CustomerService"/>
    <argument type="service" id="Acme\Resources\Service\SalesChannelService"/>
    <tag name="kernel.event_subscriber"/>
</service>

The Full Flow

  1. App authenticates the user natively
  2. App calls POST /api/app/customer with user identity fields
  3. Shopware upserts the customer and calls AccountService::loginById
  4. A row is written to sales_channel_api_context with the new token
  5. Token is returned to the app
  6. App injects the token as a sw-context-token cookie into the webview
  7. Webview loads the storefront - Shopware resolves the session from the cookie and the customer is logged in
  8. On every frontend response, AppSessionHandlerSubscriber validates the cookie token against sales_channel_api_context
  9. If the token is valid and the PHP session matches, the response is sent as normal
  10. If the PHP session has lapsed (server restart, session expiry), the subscriber re-logs in and redirects
  11. If the token is not in the database at all, a 401 is returned - the app reinitializes

What This Does Not Cover

Session timeout policy - how long tokens stay valid in sales_channel_api_context - is controlled by Shopware's core session configuration, not by this plugin. If you need long-lived sessions for a mobile app, increase the session lifetime in your Shopware configuration rather than building custom token expiry logic.

This pattern also assumes one active session per user. If the same user logs in on multiple devices, the latest loginById call overwrites the token in sales_channel_api_context. Handle multi-device scenarios at the app level if needed.


Building a Shopware storefront inside a native app? Talk to us.