Delegation, PKCE, and identity verification explained in practice
OAuth 2.0 handles authorization, OpenID Connect adds the missing identity layer, and confusing the two leads to insecure logins. This article explains the authorization code flow with PKCE as the current standard, covers common implementation mistakes around redirect URIs, state, and nonce, and shows how to integrate a social login securely into a Magento store.
Table of Contents
- 1. Delegation, not authentication: placing OAuth 2.0 correctly
- 2. Roles in the OAuth model: client, resource owner, authorization server
- 3. The authorization code flow with PKCE
- 4. OpenID Connect: the identity layer on top of OAuth
- 5. Redirect URI validation, state, and nonce
- 6. The implicit flow and other implementation mistakes
- 7. Integrating social login into a Magento store
- 8. Token storage and refresh token handling
- 9. OAuth flows and security mechanisms compared
- 10. Summary
- 11. FAQ
1. Delegation, not authentication: placing OAuth 2.0 correctly
OAuth 2.0 is an authorization protocol: it lets a client obtain limited access to a protected resource on behalf of a resource owner, without the client ever seeing the user's password. Its core idea is delegation, not proof of identity. An access token only proves that an authorization server granted a client certain rights, for a limited time and with a limited scope.
The most common conceptual mistake in practice: developers treat successfully receiving an access token as a login and derive the user's identity from it. OAuth makes no reliable statement about that, because the token endpoint delivers neither a signed user identity nor a defined way to request one. That exact gap is closed by OpenID Connect, covered in detail in section 4.
2. Roles in the OAuth model: client, resource owner, authorization server
The OAuth model defines four roles with clearly separated responsibilities. The resource owner is usually the end user who grants access. The client is the application that wants to access resources on the user's behalf, for example a Magento storefront. The authorization server authenticates the resource owner and issues access tokens after consent. The resource server holds the protected data and accepts valid access tokens as proof of access.
This separation means a client never processes or stores the user's credentials, which drastically reduces attack surface and liability risk. In practice, large identity providers such as Google or Microsoft act as both the authorization server and resource server for their own APIs, while a Magento store acts purely as a client and never sees the Google account's password.
3. The authorization code flow with PKCE
The authorization code flow is the only OAuth flow recommended today for web applications, whether the client is confidential or public. The user is redirected to the authorization server, authenticates there, and the client receives only a short-lived authorization code via the redirect, never a token directly in the browser. Only the subsequent, server-side exchange of that code for access and refresh tokens leaves the browser context, protecting it from theft by malware or malicious browser extensions.
PKCE (Proof Key for Code Exchange, RFC 7636) closes an additional gap: without PKCE, an attacker who intercepts the authorization code could exchange it for tokens themselves. Before the redirect, the client generates a random code_verifier and derives a code_challenge from it via SHA-256, which is sent with the authorization request. During the token exchange, the original code_verifier must be sent along, which only the legitimate client knows. Since 2025, the IETF recommends PKCE for all client types, not just mobile and single-page apps.
<?php
declare(strict_types=1);
// Step 1: Generate PKCE code_verifier and code_challenge (RFC 7636)
function generatePkcePair(): array
{
$verifier = rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
return ['verifier' => $verifier, 'challenge' => $challenge];
}
$pkce = generatePkcePair();
$state = bin2hex(random_bytes(16));
// Store verifier and state server-side (session), never in the URL
$session->setData('oauth_code_verifier', $pkce['verifier']);
$session->setData('oauth_state', $state);
$authorizeUrl = 'https://accounts.example.com/authorize?' . http_build_query([
'response_type' => 'code',
'client_id' => $clientId,
'redirect_uri' => 'https://mironsoft.de/customer/account/oauthCallback',
'scope' => 'openid profile email',
'state' => $state,
'code_challenge' => $pkce['challenge'],
'code_challenge_method' => 'S256',
]);
// Step 2: Callback exchanges the authorization code for tokens
function exchangeCodeForTokens(string $code, string $verifier, string $redirectUri, string $clientId, string $clientSecret): array
{
$response = (new \GuzzleHttp\Client())->post('https://accounts.example.com/token', [
'form_params' => [
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $redirectUri,
'client_id' => $clientId,
'client_secret' => $clientSecret,
'code_verifier' => $verifier, // proves this client started the flow
],
]);
return json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
}
4. OpenID Connect: the identity layer on top of OAuth
OpenID Connect (OIDC) sits as a thin identity layer directly on top of OAuth 2.0 and delivers exactly what OAuth deliberately leaves open: a standardized, verifiable proof of identity. In addition to the access token, the authorization server issues an ID token under OIDC, a signed JWT with claims such as sub (a unique user ID), iss, aud, exp, and optionally email or name. The client verifies the signature and claims cryptographically instead of blindly trusting the authorization server.
In addition, the UserInfo endpoint provides further profile data when the openid profile email scope was requested and a valid access token is presented. The important distinction: the ID token is intended for the client itself and proves authentication at a fixed point in time, while the access token is intended for the resource server and authorizes ongoing API access. Treating the two tokens separately prevents a common mix-up in implementations.
{
"id_token_claims": {
"iss": "https://accounts.example.com",
"sub": "110169484474386276334",
"aud": "594832-abc123.apps.example.com",
"exp": 1752312345,
"iat": 1752308745,
"nonce": "f3a1c9e8b2d4",
"email": "customer@example.com",
"email_verified": true,
"name": "John Doe"
},
"userinfo_endpoint_response": {
"sub": "110169484474386276334",
"given_name": "John",
"family_name": "Doe",
"email": "customer@example.com",
"email_verified": true,
"picture": "https://accounts.example.com/avatar/110169484474386276334"
}
}
5. Redirect URI validation, state, and nonce
The redirect_uri determines where the authorization server sends the code or token after sign-in, making it a primary attack target. The only safe approach is an exact string comparison against an allow-list registered with the authorization server, never a prefix or wildcard match. Loose redirect validation lets attackers redirect authorization codes to themselves via a manipulated but similar-looking URI, a classic OAuth vulnerability.
The state parameter protects against cross-site request forgery: before the redirect, the client generates a cryptographically random, single-use value, stores it server-side in the session, and compares it to the returned value on callback using a constant-time comparison. The nonce parameter serves a similar purpose in OIDC at the token level: it is returned as a claim inside the ID token and prevents replay attacks using a previously intercepted, valid token.
<?php
declare(strict_types=1);
// Allow-list of exact, pre-registered redirect URIs -- never pattern match
final class RedirectUriValidator
{
private const ALLOWED_URIS = [
'https://mironsoft.de/customer/account/oauthCallback',
];
public function assertAllowed(string $redirectUri): void
{
if (!in_array($redirectUri, self::ALLOWED_URIS, true)) {
throw new \RuntimeException('Redirect URI is not on the allow-list');
}
}
}
// Validate the state parameter on callback to prevent CSRF
function validateState(SessionManagerInterface $session, string $receivedState): void
{
$expectedState = $session->getData('oauth_state');
$session->unsetData('oauth_state'); // one-time use
if ($expectedState === null || !hash_equals((string) $expectedState, $receivedState)) {
throw new \RuntimeException('State mismatch, possible CSRF attempt');
}
}
// Validate the nonce claim inside the decoded ID token to prevent replay
function validateNonce(SessionManagerInterface $session, array $idTokenClaims): void
{
$expectedNonce = $session->getData('oauth_nonce');
$session->unsetData('oauth_nonce');
if (!hash_equals((string) $expectedNonce, (string) ($idTokenClaims['nonce'] ?? ''))) {
throw new \RuntimeException('Nonce mismatch, possible token replay');
}
}
6. The implicit flow and other implementation mistakes
The implicit flow returned access and ID tokens directly in the URL fragment to the browser, with no server-side exchange at all. That made it attractive for single-page apps without a backend, but also structurally insecure: tokens end up in browser history, server logs, and referrer headers, and there is no client authentication when the token is issued. The OAuth Security Best Current Practice (RFC 9700) has explicitly advised against this flow since 2025, in favor of the authorization code flow with PKCE, even for pure frontend applications.
Other common mistakes concern token validation itself: accepting JWTs without checking the signature, trusting the token's alg header unchecked, which allows a downgrade to alg=none, or failing to verify the aud claim against the client's own client ID. Each of these mistakes allows an attacker to reuse a token that was issued for a different client, but is technically valid, against your own application.
7. Integrating social login into a Magento store
A Google or Microsoft login in a Magento storefront follows the same pattern as any other OAuth/OIDC integration: a dedicated controller redirects to the authorization server, a second controller handles the callback, exchanges the code for tokens server-side, and maps the verified email address from the ID token to an existing or newly created Magento customer account. The account link should be based on the stable sub claim, not the email address, since the email can change at the identity provider.
On the layout side, the login button can be attached cleanly through a dedicated block in customer_account_login.xml, without modifying the existing login container. Important for Hyvä themes: the OAuth redirect itself happens outside Alpine.js, a plain server redirect is enough, and no additional JavaScript is required for the flow itself. Session creation after a successful callback still uses Magento's regular CustomerSession, so the rest of checkout and the account area keep working unchanged.
<?xml version="1.0"?>
<!-- view/frontend/layout/customer_account_login.xml -->
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceContainer name="customer.login.additional.info">
<block class="Mironsoft\SocialLogin\Block\GoogleLoginButton"
name="social.login.google"
template="Mironsoft_SocialLogin::google-login-button.phtml"
after="-">
<arguments>
<argument name="authorize_url" xsi:type="string">
{{/customer/account/oauthRedirect}}
</argument>
</arguments>
</block>
</referenceContainer>
</body>
</page>
8. Token storage and refresh token handling
Access and refresh tokens belong server-side, encrypted at rest, and never in the browser's localStorage or sessionStorage, since both are readable by any JavaScript running in the page's context and thus a direct XSS target. In Magento, EncryptorInterface is a good fit for encryption, combined with a dedicated table rather than reusing unprotected core structures. The access token itself, if it must reach the browser at all, should live exclusively in an httpOnly and secure cookie.
Refresh token rotation is mandatory: on every refresh, a new refresh token is issued and the old one is invalidated server-side. If an already-used, old refresh token shows up again, that indicates a stolen token, and the authorization server should revoke the entire token family. A short access token lifetime of a few minutes combined with rotating refresh tokens significantly limits the damage from a compromised token, without forcing the user to constantly sign in again.
<?php
declare(strict_types=1);
// Store tokens encrypted server-side, never in localStorage or a readable cookie
final class OAuthTokenStorage
{
public function __construct(
private readonly EncryptorInterface $encryptor,
private readonly CustomerTokenRepositoryInterface $tokenRepository,
) {
}
/**
* Persist access and refresh tokens encrypted at rest, tied to the customer ID.
*/
public function store(int $customerId, string $accessToken, string $refreshToken, int $expiresIn): void
{
$this->tokenRepository->save(
$customerId,
$this->encryptor->encrypt($accessToken),
$this->encryptor->encrypt($refreshToken),
time() + $expiresIn
);
}
/**
* Refresh the access token shortly before expiry using the stored refresh token.
*/
public function refreshIfNeeded(int $customerId, callable $refreshCall): ?string
{
$record = $this->tokenRepository->getByCustomerId($customerId);
if ($record === null) {
return null;
}
if ($record->getExpiresAt() - 60 > time()) {
return $this->encryptor->decrypt($record->getAccessTokenCipher());
}
$refreshToken = $this->encryptor->decrypt($record->getRefreshTokenCipher());
$tokens = $refreshCall($refreshToken); // rotates refresh token server-side
$this->store($customerId, $tokens['access_token'], $tokens['refresh_token'], $tokens['expires_in']);
return $tokens['access_token'];
}
}
9. OAuth flows and security mechanisms compared
The table below summarizes the key decisions where insecure and secure implementations most often diverge in practice.
| Aspect | Insecure / Outdated | Recommended | Why |
|---|---|---|---|
| Flow type | Implicit flow (token in URL fragment) | Authorization code flow + PKCE | No token leakage via browser history/logs |
| Redirect URI check | Prefix or wildcard match | Exact allow-list comparison | Prevents code theft via similar URIs |
| State parameter | None or a static state | Random, single-use state per request | Reliably protects against CSRF |
| PKCE method | code_challenge_method=plain | code_challenge_method=S256 | Challenge can't be rebuilt from an intercepted value |
| Token storage | localStorage in the browser | httpOnly cookie / encrypted server-side | No direct XSS access to tokens |
All five rows in the table share one thing: the secure variant rarely requires more code, usually just a deliberate configuration decision made when setting up the integration. Getting these five points right by default in every new OAuth or OIDC integration rules out the large majority of vulnerabilities observed in practice.
Mironsoft
Secure authentication, OAuth integrations, and identity architecture for Magento stores
Ready to implement OAuth and OIDC securely?
We review existing OAuth and social login integrations for PKCE, redirect URI validation, and token handling, and implement new identity provider connections to current security standards, including Magento-specific customer account mapping.
OAuth/OIDC audit
Review of PKCE, state/nonce handling, and redirect URI configuration against RFC 9700
Social login integration
Securely connect Google, Microsoft, and other identity providers to Magento and Hyvä
Token architecture
Encrypted storage, refresh token rotation, and secure session integration
10. Summary
OAuth 2.0 and OpenID Connect solve two different but closely related problems: OAuth delegates limited resource access, OIDC proves identity via a signed ID token. The authorization code flow with PKCE is the right flow for practically every client type, while the implicit flow is considered outdated since RFC 9700. Redirect URI allow-lists, state, and nonce prevent the most common OAuth-specific attacks: code interception, CSRF, and token replay.
Integrating a social login into Magento follows the same principle as any other security-relevant feature: the token exchange runs exclusively server-side, the customer mapping uses the stable sub claim, and tokens are stored encrypted with a short lifetime. Once these fundamentals are implemented cleanly, they become a reusable pattern for every additional identity provider, without having to rethink the security architecture each time.
OAuth 2.0 and OpenID Connect: The Essentials at a Glance
Authorization Code + PKCE
The recommended flow for every client type. The code exchange for tokens runs server-side, PKCE binds the code to the requesting client.
OIDC ID Token
Signed JWT with sub, iss, aud, and exp. Proves identity, kept separate from authorization in the access token.
State & Nonce
state protects against CSRF, nonce protects against token replay. Both random, single-use, and verified server-side.
Token Storage
Encrypted server-side, never in localStorage. Refresh token rotation limits the damage from stolen tokens.