Author: canutethegreat

  • 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.

  • Timing side channels: why == on a secret is a bug, and how constant-time comparison works

    Timing side channels: why == on a secret is a bug, and how constant-time comparison works

    Here’s a line of code I still find in reviews every few months:

    if request.headers["X-Signature"] == hmac.new(key, body, "sha256").hexdigest():
        process(body)

    It looks fine. The HMAC is correct, the key is secret, the comparison is a comparison. The bug is that == on strings returns as soon as it finds the first byte that differs, so the time it takes leaks how many leading bytes of the attacker’s guess were right. Given enough measurements, an attacker can recover a valid signature one byte at a time without ever knowing the key.

    How the attack works

    Illustrative chart: time of an early-exit == comparison rises in steps with each correct leading byte of a 32-byte MAC guess, while a constant-time comparison stays flat

    A short circuiting comparison does roughly one unit of work per matching byte before it bails out. If the attacker submits a signature starting with a, then b, then c and so on, the guess with the correct first character takes very slightly longer than the other fifteen hex digits. Fix that character, move to the second one, repeat. For a 64 character hex digest that’s at most 16 x 64 = 1,024 candidate positions instead of 16^64 possible values. The brute force problem goes from impossible to linear.

    The obvious objection is that a few nanoseconds per byte can’t survive network jitter. That objection is weaker than it sounds. Jitter is noise, and noise averages out with repetition. Crosby, Wallach and Riedi’s 2009 paper “Opportunities and Limits of Remote Timing Attacks” showed that with careful statistics an attacker could distinguish differences in the tens of microseconds across the internet and around 100 nanoseconds on a LAN. In 2020, “Timeless Timing Attacks” by Van Goethem and colleagues removed much of the network jitter entirely by sending two requests in the same HTTP/2 packet and only looking at which response came back first. And an attacker who can get a VM in the same cloud region as your service is not measuring across the internet at all.

    I treat any comparison against a secret value as attackable. Whether a particular one is exploitable this week is not the question I want to be answering during an incident.

    A real example: Rails basic auth

    CVE-2015-7576 is a clean case. Action Controller’s http_basic_authenticate_with compared the supplied username and password with a normal string comparison, which, in the words of the advisory, made it easier for remote attackers to bypass authentication by measuring timing differences. The fix, released in January 2016 across the supported Rails branches, switched to a constant-time comparison.

    TLS has a much more famous version of the same class of problem. Lucky Thirteen (CVE-2013-0169) recovered plaintext from CBC mode TLS because the time taken to check the MAC depended on how much padding was removed. That one took years of careful patching across every TLS library, and it’s a big part of why TLS 1.3 dropped CBC cipher suites entirely.

    What constant-time comparison does

    The idea is to do the same amount of work regardless of where, or whether, the inputs differ. Instead of returning at the first mismatch, you accumulate the differences and only look at the result at the end:

    int ct_equal(const unsigned char *a, const unsigned char *b, size_t len)
    {
        unsigned char diff = 0;
        for (size_t i = 0; i < len; i++)
            diff |= a[i] ^ b[i];
        return diff == 0;
    }

    XOR is zero only when two bytes match, and OR-ing every result together means a single mismatch anywhere leaves diff non-zero. There’s no branch that depends on the data inside the loop, so the loop takes the same time for a guess that’s wrong in the first byte as for one that’s wrong in the last.

    Please don’t write this yourself in production, though. Optimising compilers are allowed to notice that once diff is non-zero the answer is decided, and to turn your careful loop back into an early exit. Library implementations use compiler barriers or assembly to stop that, and they get tested for it. Use them:

    import hmac
    if hmac.compare_digest(received_sig, expected_sig):
        process(body)
    import "crypto/subtle"
    
    if subtle.ConstantTimeCompare(receivedMAC, expectedMAC) == 1 {
        process(body)
    }
    const crypto = require("crypto");
    const a = Buffer.from(receivedSig, "hex");
    const b = Buffer.from(expectedSig, "hex");
    if (a.length === b.length && crypto.timingSafeEqual(a, b)) {
      process(body);
    }

    Other ecosystems have the same thing: hash_equals in PHP, MessageDigest.isEqual in Java, ActiveSupport::SecurityUtils.secure_compare in Rails, CRYPTO_memcmp in OpenSSL and sodium_memcmp in libsodium.

    Length is a leak too

    Most constant-time functions are only constant-time for inputs of equal length. Go’s ConstantTimeCompare documents that it returns 0 immediately when lengths differ, and Node’s timingSafeEqual throws, which is why the JavaScript example checks length first. For HMACs and hashes that’s fine, since the length is public anyway.

    For things like API tokens where you’d rather not reveal the length, a common trick is to hash both values first (SHA-256 or an HMAC with a per-process key) and compare the fixed length digests.

    The comparisons people forget

    Token lookups are the subtle one. If your API looks up a key with SELECT ... WHERE api_key = ?, the database’s index search is itself a comparison that can leak timing about the stored values. The pattern I prefer is a split token: the first part is a non-secret identifier you look up by, and the second part is a secret you verify against a stored hash with a constant-time comparison. A leaked database then also doesn’t contain usable tokens, which is a nice side benefit.

    Login endpoints leak in a different way. If you return immediately for an unknown username but spend 200 ms running Argon2 for a known one, the response time tells an attacker which accounts exist. Run a dummy hash against a fixed value when the user isn’t found, so both paths cost the same. This ties back to the password storage post from earlier this week: a slow hash makes the timing difference larger, not smaller.

    Error paths count as well. Returning “invalid padding” and “invalid MAC” as different errors, or failing at different points, turns a timing oracle into an even easier one. Verify the MAC first, over the whole message, and give one error for everything.

    What I check in review

    Any ==, !=, equals(), strcmp or memcmp that touches a signature, MAC, token, password reset code, session identifier or API key gets flagged. A Semgrep rule for this takes a few minutes to write and is worth running in CI, because the bug is easy to reintroduce. It costs nothing to fix, it doesn’t change behaviour, and it removes a whole class of attack that doesn’t need anything clever beyond a stopwatch and patience.

  • Supply chain attacks on package registries: typosquatting, dependency confusion and what SLSA actually asks for

    Supply chain attacks on package registries: typosquatting, dependency confusion and what SLSA actually asks for

    Every npm install or pip install is a decision to run someone else’s code with your build’s permissions. Most of the time that’s a good trade. The attacks I want to cover here don’t exploit a bug in your code at all. They exploit how package managers decide which package to fetch, and how little most organisations know about where their artifacts came from.

    Typosquatting is boring and still works

    Pipeline from source repo to build platform, publish, package registry and install, with the attack that lands at each stage above it and the matching control, such as SLSA provenance, trusted publishing, scoped names and lockfile hashes, below it

    The oldest trick is publishing a package with a name one keystroke away from a popular one. In 2017 an npm package called crossenv (the real one is cross-env) copied the legitimate package’s behaviour and also sent the contents of the environment to a remote server. Environment variables on a CI runner are often the most valuable thing on the box: registry tokens, cloud credentials, signing keys. The same year Slovakia’s national security authority reported a batch of typosquats on PyPI with names like urlib3, and PyPI has been removing waves of them ever since.

    The registry operators catch a lot of these now, but not before someone installs them. The defences on your side are unglamorous. Don’t let people add dependencies by typing names into a terminal on a machine that holds credentials. Review dependency additions in pull requests the same way you review code, and look at the package’s age, download count and repository link before approving it. A two week old package with a name almost matching a library with millions of weekly downloads should not get through review.

    Dependency confusion

    Alex Birsan’s February 2021 research turned a quirk of package resolution into a way to run code inside Apple, Microsoft, PayPal and over thirty other companies, earning more than $130,000 in bug bounties. The idea is simple. Companies use internal package names that never exist on the public registry. Those names leak through public manifests, JavaScript bundles and error messages. If an attacker publishes a public package with the same name and a higher version number, many resolvers happily prefer it.

    The classic pip mistake looks like this:

    pip install --index-url https://pypi.internal.example/simple \
                --extra-index-url https://pypi.org/simple \
                acme-billing-utils

    --extra-index-url doesn’t mean “fall back to PyPI if the internal index doesn’t have it”. pip collects candidates from every index and picks the best version across all of them. If someone publishes acme-billing-utils 99.0.0 on PyPI, that’s what gets installed.

    This wasn’t a one-off. In December 2022 the PyTorch project disclosed that its nightly builds installed between December 25 and 30 had pulled a malicious torchtriton package from PyPI, because the legitimate one lived on PyTorch’s own index and PyPI took precedence. The malicious version collected system information and files from the machines that installed it.

    The structural fix is to have exactly one place your builds resolve from. Run a proxying repository (Artifactory, Nexus, a pull-through cache) that serves internal packages and mirrors public ones, and configure it so an internal name can never be satisfied from upstream. For npm, put internal packages under a scope you own and pin that scope to your registry:

    # .npmrc
    @acme:registry=https://npm.internal.example/
    registry=https://registry.npmjs.org/

    Registering your internal names (or scope) on the public registry as empty placeholders is a reasonable extra layer, but I wouldn’t rely on it alone.

    Lockfiles and hashes

    A lockfile pins exact versions, and the integrity hashes in it pin exact bytes. That turns “install whatever matches ^4.2” into “install this tarball and fail if it’s different”. Use npm ci rather than npm install in CI, since ci refuses to proceed if package-lock.json and package.json disagree. For Python, pip’s hash checking mode does the same thing:

    pip install --require-hashes -r requirements.txt

    with each line carrying --hash=sha256:..., which tools like pip-compile --generate-hashes produce. Hashes don’t protect you on the day you add or upgrade a dependency. They do stop a later build from silently picking up something different.

    Compromised maintainers

    Some attacks don’t need a lookalike name. In 2018 the maintainer of event-stream handed the project to a volunteer, who added a dependency called flatmap-stream carrying code aimed at stealing from a specific cryptocurrency wallet app. In October 2021 the npm account behind ua-parser-js was hijacked and versions shipping a cryptominer and credential stealer were published. And in 2024, CVE-2024-3094 showed a backdoor in xz utils that had been inserted after a multi-year effort to gain maintainer trust, caught largely because one engineer noticed SSH logins getting slower.

    Nothing on the consumer side fully prevents this, but you can shrink the window. Delay adopting brand new versions by a few days unless they fix something you need, because most malicious releases are spotted and pulled quickly. Watch for install scripts (preinstall, postinstall) appearing in packages that never had them, and consider npm config set ignore-scripts true for builds that don’t need them.

    What SLSA requires

    SLSA (Supply-chain Levels for Software Artifacts) is easy to misread as a scanning standard. It’s not. Version 1.0’s Build track is about provenance: a verifiable statement of which source, which build platform and which process produced an artifact.

    The levels build on each other. Build L1 means provenance exists and describes how the artifact was built. Build L2 means the build ran on a hosted platform that generates and signs that provenance itself, so a developer laptop can’t produce it. Build L3 means the platform is hardened: builds are isolated from each other, and the signing material is out of reach of the user-defined build steps, so even a malicious build script can’t forge provenance.

    What SLSA does not do is tell you whether a dependency is trustworthy. It lets a consumer check that a package came from the repository and workflow it claims to, which is exactly what you want to know after an account takeover. On the publishing side, PyPI’s Trusted Publishing and npm’s provenance support both bind packages to a specific CI workflow using short lived OIDC credentials instead of long lived API tokens. On the consuming side you verify:

    npm audit signatures
    
    slsa-verifier verify-artifact ./mytool-linux-amd64 \
      --provenance-path ./mytool-linux-amd64.intoto.jsonl \
      --source-uri github.com/example/mytool \
      --source-tag v1.4.2

    I’d prioritise it this way. Get to one resolution source and committed lockfiles with hashes first, because that kills dependency confusion and drift. Move your own publishing to trusted publishing so there’s no static token to steal. Then start verifying provenance for the dependencies that matter most, beginning with anything that runs in your build or release pipeline.

    The part people skip

    Your build environment is part of the supply chain. A CI job that installs packages while holding production deploy credentials gives every one of those packages production access. Split dependency installation from credentialed steps where you can, give build jobs only the secrets they need, and treat a compromised dependency the same as a compromised server: rotate what it could reach, don’t just bump the version.