integrated into Symfony, step by step
Single sign-on through an external identity provider takes more than redirecting a login button. This guide shows how OAuth2 and OpenID Connect are integrated cleanly into Symfony, from the authorization code flow through ID token validation to role mapping into your own user management.
Table of Contents
- 1. OAuth2 and OpenID Connect: two protocols, one misunderstanding
- 2. The authorization code flow with PKCE in detail
- 3. Setting up knpuniversity/oauth2-client-bundle
- 4. Custom OAuth2 authenticator for the callback
- 5. Validating the ID token instead of blind trust
- 6. Mapping roles from the identity provider into Symfony
- 7. Session, logout and token refresh
- 8. Common integration mistakes and their causes
- 9. OAuth2/OIDC compared to other login approaches
- 10. Summary
- 11. FAQ
1. OAuth2 and OpenID Connect: two protocols, one misunderstanding
OAuth2 is an authorization protocol, not an authentication protocol. It answers the question of which resources a client may access on behalf of a user, not who that user actually is. Exactly this misunderstanding leads many projects to wrongly treat an access token as proof of identity. OpenID Connect steps in exactly here: it extends OAuth2 with a signed ID token that makes cryptographically verifiable claims about the user's identity.
For a correct OAuth2 integration in Symfony, that means the login flow itself uses OAuth2 mechanics, but the actual authentication relies on the OIDC ID token, not the access token. This distinction is not a formality, it decides whether an application is actually secure or merely looks secure. The full guide follows this separation: OAuth2 for the authorization flow, OIDC for identity.
2. The authorization code flow with PKCE in detail
The authorization code flow is the only correct OAuth2 flow for server side Symfony applications. The browser is redirected to the identity provider, the user authenticates there, and the provider redirects back to the application with a one time authorization code. Only the Symfony server exchanges this code for an access token and an ID token server side, and the client secret never reaches the browser.
PKCE, Proof Key for Code Exchange, adds an extra layer of protection against code interception attacks: the application generates a random code_verifier, sends its hash as a code_challenge in the initial request, and must present the original verifier during the token exchange. Even though PKCE was originally designed for public clients without a secret, the current OAuth2 security BCP now recommends PKCE for every OAuth2 client, regardless of client type.
3. Setting up knpuniversity/oauth2-client-bundle
For the technical implementation in Symfony, knpuniversity/oauth2-client-bundle has become the standard, because it encapsulates the low level details of the token exchange while still leaving enough control for custom OpenID Connect logic. The configuration defines the client id, secret, and the three endpoints of the identity provider: authorization endpoint, token endpoint, and the discovery URL for the remaining OIDC metadata.
An important detail during setup: the redirect URI must match the URI registered with the identity provider exactly, including protocol, port and any trailing slash. A mismatch leads to a cryptic error from the provider that rarely points directly to the actual cause. For local development, a separate OAuth2 client entry with its own redirect URI is recommended, instead of having production and development environments share the same configuration.
# config/packages/knpu_oauth2_client.yaml
knpu_oauth2_client:
clients:
keycloak_client:
type: generic
# These three values come from your identity provider's admin console
client_id: '%env(OIDC_CLIENT_ID)%'
client_secret: '%env(OIDC_CLIENT_SECRET)%'
redirect_route: connect_oidc_check
redirect_params: {}
# Discovery endpoint resolves authorization/token/jwks URLs automatically
urlAuthorize: '%env(OIDC_AUTH_ENDPOINT)%'
urlAccessToken: '%env(OIDC_TOKEN_ENDPOINT)%'
urlResourceOwnerDetails: '%env(OIDC_USERINFO_ENDPOINT)%'
scopes: ['openid', 'profile', 'email']
4. Custom OAuth2 authenticator for the callback
The callback after a successful login at the identity provider lands in a custom authenticator built on top of OAuth2Authenticator from the bundle. It exchanges the authorization code for the tokens, calls the userinfo endpoint if needed, and loads or creates the local user based on a stable identifier, usually the sub claim from the ID token, never the email address, which can change at the provider.
For new users logging in via OAuth2 for the first time, the application decides whether a local account is created automatically, called just in time provisioning, or whether an explicit invitation is required. Just in time provisioning is convenient for internal tools with a trusted identity provider, but risky for public applications, where any arbitrary Google or Microsoft account would otherwise gain access automatically.
<?php
declare(strict_types=1);
namespace App\Security;
use App\Repository\UserRepository;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use KnpU\OAuth2ClientBundle\Security\Authenticator\OAuth2Authenticator;
use League\OAuth2\Client\Provider\GenericProvider;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Routing\RouterInterface;
final class OidcAuthenticator extends OAuth2Authenticator
{
public function __construct(
private readonly ClientRegistry $clientRegistry,
private readonly UserRepository $users,
private readonly RouterInterface $router,
) {
}
public function supports(Request $request): ?bool
{
return $request->attributes->get('_route') === 'connect_oidc_check';
}
public function authenticate(Request $request): Passport
{
/** @var GenericProvider $client */
$client = $this->clientRegistry->getClient('keycloak_client')->getOAuth2Provider();
$accessToken = $this->fetchAccessToken($this->clientRegistry->getClient('keycloak_client'));
// The subject claim is the stable, provider-issued identifier — never the email
$idTokenClaims = $this->decodeAndVerifyIdToken((string) $accessToken->getValues()['id_token']);
$subject = $idTokenClaims['sub'];
return new SelfValidatingPassport(new UserBadge($subject, function (string $sub) use ($idTokenClaims) {
$user = $this->users->findByOidcSubject($sub);
if (null === $user) {
throw new CustomUserMessageAuthenticationException('No local account provisioned for this identity.');
}
return $user;
}));
}
public function onAuthenticationSuccess($request, $token, string $firewallName): ?RedirectResponse
{
return new RedirectResponse($this->router->generate('app_dashboard'));
}
}
5. Validating the ID token instead of blind trust
The ID token is a signed JWT, and this signature must be verified before any claim inside it is trusted. Verification runs against the public key of the identity provider, published at the JWKS endpoint from the OIDC discovery metadata. A library such as web-token/jwt-framework reliably handles signature verification, key rotation and validation of the standard claims like exp, iss and aud, a manual JWT parser without full signature verification is not secure enough for OpenID Connect.
Three claims deserve special attention in every OpenID Connect integration: iss must match the expected issuer URL exactly, otherwise a token could originate from a different, potentially compromised provider. aud must contain the application's own client id, otherwise the token was issued for a different application. exp must be in the future, with a small tolerance of a few seconds for clock drift between server and provider.
<?php
declare(strict_types=1);
namespace App\Security\Oidc;
use Jose\Component\Checker\ClaimCheckerManager;
use Jose\Component\Checker\ExpirationTimeChecker;
use Jose\Component\Checker\IssuedAtChecker;
use Jose\Component\Signature\JWSVerifier;
final class IdTokenValidator
{
public function __construct(
private readonly JWSVerifier $jwsVerifier,
private readonly ClaimCheckerManager $claimCheckers,
private readonly string $expectedIssuer,
private readonly string $clientId,
) {
}
public function validate(string $idToken): array
{
// 1. Signature must verify against the provider's published JWKS
$jws = $this->deserializeAndVerifySignature($idToken);
$claims = json_decode($jws->getPayload(), true, flags: JSON_THROW_ON_ERROR);
// 2. iss, aud and exp are checked explicitly — never trust unverified claims
if (($claims['iss'] ?? null) !== $this->expectedIssuer) {
throw new \RuntimeException('Unexpected issuer.');
}
if (!in_array($this->clientId, (array) ($claims['aud'] ?? []), true)) {
throw new \RuntimeException('Token was not issued for this client.');
}
$this->claimCheckers->check($claims, [new ExpirationTimeChecker(), new IssuedAtChecker()]);
return $claims;
}
}
6. Mapping roles from the identity provider into Symfony
An identity provider often delivers its own group or role claims, for example groups at Keycloak or roles at Auth0, which do not have to map one to one to Symfony roles. A central mapping translates these external claims into internal roles like ROLE_ADMIN or ROLE_EDITOR, ideally through a configurable mapping table instead of hardcoded if branches, so that changes to the provider side role scheme do not immediately require code changes.
A subtle mistake in OAuth2/OIDC role mapping: roles are only picked up on the first login and never updated afterwards, even though group membership at the provider has long since changed. A robust pattern re synchronizes roles from the current ID token claims on every login, instead of relying on a one time import. That way a departed employee is not accidentally left with stale admin rights in the application.
# config/packages/oidc_role_mapping.yaml
parameters:
# Configurable mapping instead of hardcoded if-branches —
# updating the provider's group scheme never requires a code change
oidc.role_mapping:
'keycloak-admins': 'ROLE_ADMIN'
'keycloak-editors': 'ROLE_EDITOR'
'keycloak-support': 'ROLE_SUPPORT'
# Any group not listed here maps to no additional role
7. Session, logout and token refresh
After successful OpenID Connect authentication, Symfony runs a normal, classic session, independent of the external provider. That means a logout in the Symfony application does not by default also log the user out at the identity provider. For true single sign out, an additional redirect to the provider's end_session_endpoint is needed, usually supplied by the OIDC discovery metadata.
The access token itself is usually no longer needed after the initial login, unless the application actively calls the identity provider's APIs. If it does, a refresh token ensures that an expired access token can be renewed without another login redirect. Just as with plain API token authentication, this refresh token belongs exclusively server side, never in the browser or in a client side cookie.
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\Routing\Attribute\Route;
final class SingleSignOutController extends AbstractController
{
#[Route('/logout/complete', name: 'app_single_sign_out')]
public function __invoke(): RedirectResponse
{
// Local Symfony session is already cleared by the firewall's logout handler.
// This redirect additionally ends the session at the identity provider.
$endSessionEndpoint = $this->getParameter('oidc.end_session_endpoint');
return new RedirectResponse($endSessionEndpoint . '?post_logout_redirect_uri=' . urlencode(
$this->generateUrl('app_home', [], 0)
));
}
}
8. Common integration mistakes and their causes
The most common mistake in OAuth2 integrations: the access token is used directly for authentication, without ever requesting or validating the ID token. An access token has no guaranteed, standardized format, its validity often can only be checked through an additional request to the provider, which adds latency and an extra source of failure. The ID token, by contrast, is local, offline and cryptographically verifiable, exactly what it was designed for.
A second common mistake: the state parameter of the authorization code flow is not checked, which makes the application vulnerable to cross site request forgery in the login flow. The knpuniversity/oauth2-client-bundle handles state correctly automatically, a manually built flow without a bundle must implement this step explicitly, otherwise an attacker can inject a foreign authorization code into the victim's session.
9. OAuth2/OIDC compared to other login approaches
Not every project benefits from a full OpenID Connect integration. The following overview ranks login strategies by effort and suitability, depending on whether a central identity provider already exists in the organization.
| Login approach | Suitability | Effort | When it makes sense |
|---|---|---|---|
| Classic form login | Single application | Low | No central identity provider available |
| OAuth2 + homegrown ID token | Insecure | Medium | Never, not a substitute for OIDC |
| OAuth2 + OpenID Connect | Enterprise SSO | Medium to high | Central provider exists, multiple applications |
| Social login without own account | Consumer apps | Low to medium | Public registration desired |
As soon as an organization runs more than one internal application, the extra effort of a clean OAuth2 plus OIDC integration pays off quickly: users manage access only at the central provider, password policies and multi factor requirements apply automatically to all connected applications, and an employee's departure requires only a single deactivation instead of many separate accounts.
Mironsoft
Single sign-on, identity integration and Symfony backend
Cleanly connecting single sign-on to your identity provider?
We integrate OAuth2 and OpenID Connect into Symfony, including correct ID token validation, role mapping from your existing provider, and working single sign out.
SSO integration
Connect Keycloak, Auth0, Azure AD or your own provider
Security review
Check existing OAuth2 flows for token confusion and CSRF
Role mapping
Configurable mapping from provider claims to Symfony roles
10. Summary
OAuth2 governs authorization, OpenID Connect adds authentication through a signed ID token, and this distinction decides the security of the entire integration. The authorization code flow with PKCE is the correct flow for Symfony applications, the knpuniversity/oauth2-client-bundle handles the low level mechanics, and a custom authenticator processes the callback and loads the local user based on the stable sub claim.
The ID token signature must always be verified, iss, aud and exp are the three critical claims. Roles should be re synchronized from the provider claims on every login, not only on the first one. Anyone running more than one application in the organization benefits from the centralized access management that a clean OpenID Connect integration enables.
OAuth2 and OpenID Connect in Symfony — The Key Points at a Glance
OAuth2 vs. OIDC
OAuth2 governs authorization, OIDC adds a signed ID token for actual identity verification.
Authorization code flow + PKCE
The only correct flow for server side Symfony apps, PKCE adds extra protection against code interception.
Always validate the ID token
Verify the signature against JWKS, check iss, aud and exp, never trust unverified claims.
Sync roles on every login
No one time import, but continuous synchronization from the current provider claims.