Impersonation and Switching Users
switch_user lets support staff see the application as a customer; it needs a narrow role, an audit log, and a visible way out.
GET /[email protected] (leave with ?_switch_user=_exit)Support work often needs the customer's own view of a page. Impersonation gives it to you: the firewall swaps the security token for the target user's, and from that request on the application behaves as if they were logged in.
Turning it on
Set switch_user: true on the firewall. That shorthand means the same as switch_user: { parameter: _switch_user, role: ROLE_ALLOWED_TO_SWITCH }, which is the defaults written out. Then append [email protected] to any URL inside that firewall, and ?_switch_user=_exit to stop.
Note what that means: there is no route to protect. The parameter is honoured on every URL the firewall covers. The only gate is the role option, checked through the usual authorization machinery, so the role hierarchy applies to it as well.
Making it safe
Three things turn a convenience into something you can run in production. First, keep the role narrow, and do not let it ride along on an inherited admin role by accident. Second, log every switch. The firewall dispatches a SwitchUserEvent; a listener on it is your audit trail, and the only record that an action was taken by staff rather than by the customer. Third, make the exit visible: is_granted('IS_IMPERSONATOR') is true while impersonating, and impersonation_exit_path() builds the link out.
For finer control, give role a custom attribute instead of a role name. The user being switched to is passed as the subject, so a voter can decide which accounts a given staff member may enter.
Example
<?php
// src/Security/SwitchUserAuditListener.php
declare(strict_types=1);
namespace App\Security;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Security\Core\Authentication\Token\SwitchUserToken;
use Symfony\Component\Security\Http\Event\SwitchUserEvent;
#[AsEventListener]
final class SwitchUserAuditListener
{
public function __construct(private readonly LoggerInterface $logger)
{
}
public function __invoke(SwitchUserEvent $event): void
{
$token = $event->getToken();
// while impersonating, the new token wraps the real staff token;
// on _exit it is an ordinary token, so there is no impersonator to record
$impersonator = $token instanceof SwitchUserToken
? $token->getOriginalToken()->getUser()?->getUserIdentifier()
: null;
$this->logger->notice('User impersonation changed', [
'target' => $event->getTargetUser()->getUserIdentifier(),
'impersonator' => $impersonator,
'ip' => $event->getRequest()->getClientIp(),
]);
}
}
// Switching in writes: [email protected] [email protected]
// Leaving with _exit writes: [email protected] impersonator=nullWhen to use it
- Reproduce a bug a customer reported, on their own data, without asking for their password.
- Record who impersonated whom, and when, for an audit review.
- Allow support staff into customer accounts but never into another staff account.
More examples
Shorthand and longhand configuration
Giving ROLE_ALLOWED_TO_SWITCH its own parent role keeps impersonation out of the general admin role, which is where it tends to end up unnoticed. target_route stops support staff from landing on whichever page they happened to be on when they switched.
# config/packages/security.yaml
security:
role_hierarchy:
# grant the switching role on its own, not as part of ROLE_ADMIN
ROLE_SUPPORT: [ROLE_USER, ROLE_ALLOWED_TO_SWITCH]
firewalls:
main:
# shorthand
switch_user: true
# the same thing, with the defaults written out
# switch_user:
# parameter: _switch_user
# role: ROLE_ALLOWED_TO_SWITCH
# a stricter variant: a header instead of a query parameter,
# and a fixed landing page after switching
# switch_user:
# parameter: X-Switch-User
# role: ROLE_SUPPORT
# target_route: app_account_dashboardThe way out of an impersonated session
Put this banner in the base template, not on one page. An impersonated session lasts until it is exited, so staff who forget they are in one will otherwise read, and write, as the customer for the rest of the day.
{# templates/base.html.twig #}
{% if is_granted('IS_IMPERSONATOR') %}
<div>
Viewing as {{ app.user.userIdentifier }}.
{# builds the ?_switch_user=_exit URL; the argument is where to land #}
<a href="{{ impersonation_exit_path(path('app_admin_dashboard')) }}">
Stop impersonating
</a>
{# the longhand: the same URL, assembled by hand #}
{# <a href="{{ path('app_admin_dashboard', {_switch_user: '_exit'}) }}">Stop</a> #}
</div>
{% endif %}Deciding who may be impersonated
The role option is an authorization attribute, so it can be any string a voter understands rather than a role name. This is how you stop one support agent from entering a colleague's account, or limit impersonation to the customers in an agent's own region.
<?php
// src/Security/Voter/SwitchUserVoter.php
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
// paired with: switch_user: { role: CAN_SWITCH_USER }
final class SwitchUserVoter extends Voter
{
protected function supports(string $attribute, mixed $subject): bool
{
// the firewall passes the user being switched TO as the subject
return 'CAN_SWITCH_USER' === $attribute && $subject instanceof User;
}
protected function voteOnAttribute(
string $attribute,
mixed $subject,
TokenInterface $token,
?Vote $vote = null,
): bool {
$staff = $token->getUser();
if (!$staff instanceof User || !\in_array('ROLE_SUPPORT', $staff->getRoles(), true)) {
return false;
}
// never let support step into another staff account
if (\in_array('ROLE_SUPPORT', $subject->getRoles(), true)) {
$vote?->addReason('Staff accounts cannot be impersonated.');
return false;
}
return true;
}
}
Discussion