Two layers:
roles.The bundle handles authorization. Authentication stays a firewall concern; the bundle never inspects credentials directly.
By default, omitting roles makes the method public — the dispatcher skips
authorization entirely:
#[Rpc\Method('public.ping')]
final class Ping
{
public function __invoke(): array { return ['pong' => true]; }
}
Anonymous requests pass through, provided your firewall also allows them on
the /rpc route.
Switching to secure-by-default. Set
security.default_roles(see Configuration reference) to flip the default: every method without explicitroles:inherits the listed roles, and onlypublic_prefixes/public_methodsstay anonymous. Useprefix_roles(e.g.admin.* → ROLE_ADMIN) to apply per-prefix defaults without puttingroles:on every handler.
#[Rpc\Method('user.delete', roles: ['ROLE_ADMIN'])]
final class DeleteUser { /* … */ }
On call, the dispatcher checks AuthorizationCheckerInterface::isGranted()
against each role. Missing role → throws AccessDeniedException (-32001).
If symfony/security-bundle isn’t installed but a method declares roles, the
bundle throws at the first call with a clear “install symfony/security-bundle”
message — no silent bypass.
// Any (default) — at least one role matches.
#[Rpc\Method('billing.refund', roles: ['ROLE_SUPPORT', 'ROLE_ADMIN'])]
// All — every role must match.
#[Rpc\Method(
'compliance.export',
roles: ['ROLE_ADMIN', 'ROLE_COMPLIANCE'],
rolesMatch: RoleMatch::All,
)]
Change the default for methods that omit rolesMatch:
json_rpc_server:
security:
roles_match: all # or 'any'
The default AccessDenied message names the missing role(s):
One of the following roles is required: ROLE_BILLING_INTERNAL_ADMIN
Helpful in dev. In prod, some teams treat role identifiers as internal — flip the config knob:
json_rpc_server:
security:
expose_role_names: false
Now the message is just Access denied. The HTTP body still carries
error.code: -32001, just without the leak.
The bundle ships nothing for the firewall side. Typical setup if your /rpc is
JWT-authenticated:
# config/packages/security.yaml
security:
firewalls:
rpc:
pattern: ^/rpc
stateless: true
jwt: ~
# or whatever your auth scheme is
Whatever ends up in the token storage as the UserInterface becomes
Context::$user and feeds RoleMatch checks.
public function __invoke(MyRequest $req, Context $ctx): array
{
$userId = $ctx->user?->getUserIdentifier(); // null for anon
$isAdmin = $ctx->hasRole('ROLE_ADMIN');
// …
}
Context is read-only and per-call. See Context.
If you’re using #[Rpc\Cache], the bundle ships UserScope so cached
entries are keyed per user identifier:
#[Rpc\Method('user.profile', roles: ['ROLE_USER'])]
#[Rpc\Cache(ttl: 60, scope: UserScope::class)]
final class GetMyProfile { /* … */ }
See Caching.
RateLimitScope::User keys the rate-limit counter on the user identifier:
#[Rpc\Method('billing.heavyReport', roles: ['ROLE_USER'])]
#[Rpc\RateLimit(limit: 5, intervalSec: 60, scope: RateLimitScope::User)]
final class HeavyReport { /* … */ }
Anonymous callers all share the same anon slot — typically that’s what you
want (rate-limit anonymous traffic harshly).
/rpc, /mcp/call, /rpc/streamrolesMatch: All for high-risk methodsexpose_role_names: false in productionscope: Ip)max_request_size set to your maximum acceptable payload (default 1 MB)mcp.apply_rate_limit: true