OAuth 2.0 and OpenID Connect: Fundamentals for Developers
AI generated
OWASP
0x00
Security · OAuth 2.0 · OpenID Connect · Web Standards
OAuth 2.0 and OpenID Connect: Fundamentals for Developers
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.

17 min read Authorization Code Flow · PKCE OpenID Connect · Social Login

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.

11. FAQ: OAuth 2.0 and OpenID Connect

1What is the difference between OAuth 2.0 and OpenID Connect?
OAuth 2.0 delegates limited resource access. OpenID Connect adds an identity layer with a signed ID token, proving who actually signed in.
2Why is the implicit flow no longer recommended?
Tokens end up unprotected in the URL fragment, in history and logs, with no client authentication. RFC 9700 has explicitly advised against it since 2025.
3What does PKCE do, and is it only needed for mobile apps?
PKCE binds the code to the requesting client via code_verifier/code_challenge. Recommended for all client types since RFC 9700, not just mobile/SPA.
4What exactly is the state parameter for?
Protects against CSRF: a random, single-use value is generated before the redirect and checked against the returned value on callback.
5What exactly is the nonce parameter for?
In OIDC it protects against replay of intercepted ID tokens by being returned as a claim and checked against the value that was sent.
6How do I validate a redirect_uri securely?
Exact string comparison against a server-side allow-list. No prefix or wildcard matching, which enables code theft.
7Where should I store access and refresh tokens?
Encrypted, server-side. If needed in the browser, only in an httpOnly/secure cookie, never localStorage.
8What is refresh token rotation and why does it matter?
Every refresh issues a new token and invalidates the old one. Reuse of an old token signals theft.
9How do I map a social login user to a Magento customer account?
Via the stable sub claim from the ID token, not the email address, which can change at the provider.
10Is a valid access token sufficient proof of identity?
No, an access token only proves resource access. Identity requires the signed ID token from OpenID Connect.