Skip to content

DPoP: htu claim contains percent-encoded path while server expects decoded form, causing 400 invalid_dpop_proof #541

@yannrouillard

Description

@yannrouillard

Summary

When DPoP is enabled and an SDK method takes a parameter that ends up
percent-encoded in the URL path (e.g. an email login containing @), the
htu claim in the DPoP proof JWT contains the percent-encoded form, while
the Okta server appears to normalize the request URI (decoding percent-
encoded characters like @) before comparing it to htu. The mismatch
causes every such request to fail with HTTP 400 invalid_dpop_proof /
'htu' claim in the DPoP proof JWT is invalid.

Environment

  • okta SDK version: 3.4.2 (also confirmed on 3.4.0)
  • Python: 3.13
  • DPoP enabled on the Okta application (dpopEnabled: True)
  • Auth mode: PrivateKey (client_credentials with private_key_jwt)

Reproduction

import asyncio
from okta.client import Client as OktaClient

config = {
    "orgUrl": "https://<your-org>.okta.com",
    "authorizationMode": "PrivateKey",
    "clientId": "<client_id>",
    "scopes": ["okta.users.read"],
    "privateKey": open("private_key.pem").read(),
    "dpopEnabled": True,
}

async def main():
    okta = OktaClient(config)

    # Fails: path contains '@' which the SDK encodes as '%40' in htu
    user, resp, err = await okta.get_user("test.user@example.com")
    print("by email →", resp.status_code)  # 400 invalid_dpop_proof

    # Works: bare ID, no special chars in path
    user, resp, err = await okta.get_user("00uXXXXXXXXXXXXXXXXX")
    print("by id    →", resp.status_code)  # 200

asyncio.run(main())

The 400 response includes:

WWW-Authenticate: DPoP ... error="invalid_dpop_proof",
  error_description="'htu' claim in the DPoP proof JWT is invalid."

Decoding the DPoP proof JWT confirms the SDK puts the encoded form into htu:

htu = "https://<your-org>.okta.com/api/v1/users/test.user%40example.com"

Root cause

The path is percent-encoded on the way out and never decoded for htu:

  1. api_client.py:param_serialize() builds the path via
    quote(value, safe=config.safe_chars_for_path_param) with
    safe_chars_for_path_param = "", encoding @ to %40.
  2. The same encoded URL is passed to dpop.generate_proof_jwt(http_url=...),
    and utils.normalize_dpop_url() only strips query/fragment. So htu
    carries %40.
  3. The server appears to compare against the URI with @ decoded → mismatch → 400.

Affects any path parameter containing chars the SDK encodes (@, +, :,
sub-delims, …). Login by email is the common case.

Workaround

Monkey-patch normalize_dpop_url to also percent-decode the path component
before it goes into the proof:

from urllib.parse import urlparse, urlunparse, unquote
from okta import utils as okta_utils
from okta import dpop as okta_dpop

_orig = okta_utils.normalize_dpop_url
def _patched(url: str) -> str:
    normalized = _orig(url)
    p = urlparse(normalized)
    return urlunparse((p.scheme, p.netloc, unquote(p.path), '', '', ''))

okta_utils.normalize_dpop_url = _patched
okta_dpop.normalize_dpop_url = _patched  # already imported by name

With this patch, get_user("test.user@example.com") succeeds (200).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions