Tag: OpenID Connect

  • OAuth 2.0 vs OpenID Connect: what each one solves, PKCE, and the end of the implicit flow

    OAuth 2.0 vs OpenID Connect: what each one solves, PKCE, and the end of the implicit flow

    Part 3 of 4 in the Authentication done right series. Part 1: Password storage in 2026. Part 2: MFA that survives phishing.

    The first two parts of this series were about proving a person is who they say they are. This one is about what happens next, when one piece of software needs to act on that person’s behalf or another service needs to know who logged in. Those are two different problems, and a lot of security bugs I’ve seen come from using the tool for one to solve the other.

    OAuth 2.0 is about delegation

    Sequence diagram of the OIDC authorization code flow with PKCE between browser, client backend, authorization server and API, showing the code challenge, the back-channel token exchange with the verifier, the ID token used for login and the access token sent to the API

    OAuth 2.0 (RFC 6749) answers one question: how does a user let an application access an API without giving the application their password? The user authenticates to an authorization server, approves a set of scopes, and the application gets an access token it presents to a resource server. The resource server cares that the token is valid and grants the right scopes. It doesn’t necessarily care, or learn, who the user is.

    That’s the important part. An access token is a capability, like a valet key for a car. It says “the holder may do these things”. It isn’t a statement about identity, and it isn’t addressed to your application in any cryptographic sense. RFC 6750 calls them bearer tokens because whoever bears one can use it.

    OpenID Connect is about authentication

    OpenID Connect (OIDC Core 1.0, finalised in 2014) is a layer on top of OAuth that adds what OAuth deliberately left out. When the client asks for the openid scope, the authorization server also returns an ID token: a signed JWT with an issuer (iss), a subject (sub), an audience (aud) that is your client ID, an expiry, and a nonce that ties it to the login request your app started.

    The ID token is addressed to your application. That’s what makes it safe to use for login, and it’s why “Sign in with” buttons should be OIDC rather than plain OAuth plus a call to some profile endpoint.

    Token confusion

    The classic mistake is using an OAuth access token as proof of login. The app receives an access token, calls the provider’s user info endpoint, sees a user ID, and logs that user in. The problem is that the app never checked that the token was issued to it. Any other application that integrates with the same provider receives access tokens for its own users. If one of those applications is malicious, or simply compromised, it can replay a user’s token against your login endpoint and become that user in your app. This pattern hit a lot of mobile apps that implemented social login in the early 2010s, and I still see it in internal tools.

    The fix is to authenticate with the ID token and validate it properly, or, if you must accept access tokens, to use the provider’s token introspection and verify the client the token was issued to. Validating an ID token means checking everything, not just the signature:

    import hmac
    import jwt
    from jwt import PyJWKClient
    
    jwks = PyJWKClient("https://idp.example.com/.well-known/jwks.json")
    
    def validate_id_token(id_token, expected_nonce):
        key = jwks.get_signing_key_from_jwt(id_token).key
        claims = jwt.decode(
            id_token,
            key,
            algorithms=["RS256"],          # never take the alg from the token
            audience=CLIENT_ID,            # issued to us, not some other app
            issuer="https://idp.example.com",
            options={"require": ["exp", "iat", "iss", "aud", "sub", "nonce"]},
        )
        if not hmac.compare_digest(claims["nonce"], expected_nonce):
            raise ValueError("nonce mismatch")
        return claims["sub"]

    Key the user record on iss plus sub, not on email. Email addresses get reassigned and some providers let users set unverified ones.

    The reverse confusion matters too. APIs should not accept ID tokens as access tokens. An ID token’s audience is the client, not the API, and treating it as an API credential throws away scopes entirely.

    The authorization code flow, with PKCE

    The standard flow redirects the user to the authorization server, which redirects back with a short lived authorization code. The client then exchanges that code for tokens over a direct back channel request. Tokens never pass through the browser’s address bar.

    The weak point was the code itself. On mobile, other apps could register the same custom URL scheme and intercept the redirect. PKCE (RFC 7636) fixes this. The client generates a random secret, sends only its hash in the authorization request, and proves it knows the original when redeeming the code:

    import base64, hashlib, secrets
    
    def b64url(b):
        return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
    
    code_verifier = b64url(secrets.token_bytes(32))
    code_challenge = b64url(hashlib.sha256(code_verifier.encode()).digest())
    
    # authorization request
    #   ...&code_challenge=<code_challenge>&code_challenge_method=S256
    # token request
    #   ...&code=<code>&code_verifier=<code_verifier>

    An attacker who steals the code doesn’t have the verifier, so the code is useless. PKCE also blocks authorization code injection, where an attacker pushes a code from their own session into the victim’s client. RFC 9700, the OAuth 2.0 Security Best Current Practice published in January 2025, recommends PKCE for every client type, confidential server side apps included. Use S256, never plain.

    Why the implicit flow is gone

    The implicit flow was designed for browser apps in 2012, when cross origin requests to a token endpoint weren’t practical. The authorization server put the access token directly in the redirect URL fragment. That meant tokens ended up in browser history, could leak through referrers and open redirects, could be injected by an attacker, and couldn’t be bound to the client that requested them. There was also no safe way to issue refresh tokens.

    CORS has been universal for a long time, so none of those trade-offs are needed anymore. RFC 9700 says clients should not use the implicit grant, and OAuth 2.1 drops it entirely, along with the resource owner password credentials grant. Single page apps should use the code flow with PKCE. Better still, use a backend-for-frontend: a small server side component that performs the OAuth flow, keeps tokens out of JavaScript entirely, and gives the browser a session cookie. That’s where the next part of this series picks up.

    Smaller things that cause real incidents

    Redirect URIs must be matched exactly. Prefix or wildcard matching has led to codes being sent to attacker controlled paths more than once. The state parameter still matters for CSRF on the redirect endpoint if you’re not relying on PKCE for that. If your client talks to more than one authorization server, check the iss parameter in the authorization response (RFC 9207) to stop mix-up attacks, where one server’s code is redeemed at another. And for high value APIs, consider sender constrained tokens with DPoP (RFC 9449) or mutual TLS (RFC 8705), so a stolen access token is useless without the client’s private key.

    Keep access tokens short lived, scope them narrowly, and give APIs a way to tell them apart from ID tokens. Most OAuth problems I’ve cleaned up were not protocol flaws. They were one token type being trusted for a job it was never meant to do.

    Other parts: Part 1: Password storage in 2026, Part 2: MFA that survives phishing, Part 4: Session management.