Advanced security configuration

Overview

This vignette documents the configuration of OAuth 2.0 and OpenID Connect extensions supported by shinyOAuth: JWT client authentication, mutual TLS (mTLS), signed authorization requests (JAR), pushed authorization requests (PAR), Form Post responses, signed authorization responses (JARM), and tokens bound to a private key (DPoP).

These features require corresponding support and configuration at the provider. The examples extend the provider and client setup in the usage vignette. Replace placeholder domains, credentials, and key paths with your registered values. Each example shows the settings for the feature being discussed.

Browser binding and deployment boundary

Use HTTPS and treat every service on the same hostname as trusted, including services on other ports. Cookie scope does not include the port, as specified in RFC 6265 section 8.5. The __Host- prefix prevents sibling-domain cookie injection; it does not isolate ports. Signed or HttpOnly cookies still reach same-host services in HTTP requests. Use a dedicated hostname when those services are untrusted.

shinyOAuth keeps the actual browser-binding token in origin- and tab-scoped session storage, accompanied by an independent random cookie marker. It restores the binding only when the cookie matches an unexpired local record. Reading or planting a cookie on another port cannot establish that record. Cookie disruption can still abort login; pages and scripts on the same origin can access session storage, so XSS prevention remains necessary. Cookies, session storage, and Web Crypto must be available, and pending logins from older cookie-only or local-storage versions must be restarted after upgrading.

Each new authorization request gets a fresh server-selected binding and a distinct marker cookie. Idle predecessor markers are removed when the new binding is ready. Predecessors associated with pending transactions (or older records whose transaction status is unknown) remain until their original TTL expires, so a cloned tab cannot invalidate the original tab’s pending login. Application callback routes and tabs have independent records; complete login in the tab that started it. Private module inputs are excluded from both URL and disk bookmarks. Avoid copying browser tokens into custom bookmark values or logs. The acknowledgment is a delivery check, not independent server verification of HTTP cookie possession. The standalone prepare_call() and handle_callback() APIs still require the caller to establish and protect their own browser binding.

Provider metadata and OIDC discovery

oauth_provider_oidc_discover() reads provider metadata used by these features, including PAR support, JARM and DPoP algorithms, and mTLS endpoint aliases:

provider <- oauth_provider_oidc_discover(
  issuer = "https://id.example.com"
)

The sections below show the extra settings you usually add on top of your normal oauth_client() setup.

JWT client authentication

Some providers require your app to sign a short statement proving its identity when requesting tokens. This client assertion is a JWT (JSON Web Token). It identifies the app, rather than the user signing in.

For a registered private key, select the method during provider setup and supply the key when creating your client:

provider <- oauth_provider_oidc_discover(
  "https://id.example.com", token_auth_style = "private_key_jwt"
)
client <- oauth_client(
  provider = provider,
  client_id = "client-id",
  redirect_uri = "https://app.example.com",
  scopes = c("openid", "profile"),
  client_assertion_private_key = openssl::read_key("keys/client-key.pem"),
  client_assertion_private_key_kid = "registered-key-id"
)

Register the corresponding public key with your provider. For client_secret_jwt, select that method and supply a sufficiently strong client_secret instead. client_assertion_alg has a key-compatible default; client_assertion_audience overrides the expected recipient if your provider requires a value other than the token request URL.

For the client assertion profile referenced by OAuth 2.1 draft 16, select the trusted authorization-server issuer as the sole audience and optionally use the recommended explicit type:

client@client_assertion_audience <- provider@issuer
client@client_assertion_typ <- "client-authentication+jwt"

RFC7523bis draft 11 section 4 requires the issuer audience and recommends this type. The package preserves the existing JWT type and request-endpoint audience defaults, including PAR’s existing issuer handling. The checker treats audience mismatches as mandatory findings and legacy typing as an advisory. These settings affect client authentication assertions, including retries, and do not change JAR, JARM or ID token headers. The same settings can be selected in endpoint_auth overrides.

Authentication can differ at PAR, introspection, and revocation endpoints. Discovery preserves their independent method and algorithm metadata; configure credentials and audiences to match each endpoint’s registration agreement:

client@endpoint_auth <- list(
  introspection = list(
    token_auth_style = "header",
    client_id = "registered-inspector",
    client_secret = Sys.getenv("INTROSPECTION_SECRET"),
    extra_headers = c("X-App" = "registered-app")
  ),
  revocation = list(
    token_auth_style = "private_key_jwt",
    client_assertion_private_key = openssl::read_key("keys/revocation-key.pem"),
    client_assertion_alg = "RS256",
    client_assertion_audience = "https://id.example.com/revocation"
  )
)

Unspecified credentials inherit the client’s settings. Advertised methods and JWT algorithms are checked per endpoint. Discovery defaults omitted revocation methods to Basic authentication; omitted introspection methods have no standard default, so confirm the configured method with your provider. PAR inherits the token authentication settings unless explicitly overridden. extra_token_headers now applies only to exchange and refresh: opt in through extra_headers at each other endpoint that should receive those headers, even on the same origin.

Issuer comparison and transport policy

compare_callback_issuer = TRUE compares a supplied callback iss exactly against provider@issuer. Pair it with enforce_callback_issuer = FALSE when absence is permitted for your older provider. Required presence always implies comparison. An explicitly supplied legacy enforce_callback_issuer = FALSE retains complete opt-out unless comparison is explicitly enabled. Otherwise, issuer-configured clients enable comparison automatically.

Participating clients require iss when the provider advertises RFC 9207 support. Validated JARM supplies its own issuer and does not require a redundant outer value. Multi-server applications must retain their selected issuer or distinct-route defense. The expected issuer comes from trusted configuration; it is not inferred from a token URL or normalized before comparison. See RFC 9207 section 2.4.

options(shinyOAuth.tls_min_version = "1.2") requires TLS 1.2 or later on package HTTPS requests; "1.3" selects a higher minimum and NULL preserves runtime defaults. Configure it before discovery or login. The policy preserves stronger supplied minima, compatible maxima and custom CA roots. It also applies in async workers and participates in pending-login policy checks. An absent option is insufficient evidence about an actual handshake; the checker reports older unresolved backend defaults as unknown. Browser/proxy hops and future resource requests need separate validation. Details and constraints are in package options.

Callback pages and application scripts

Use oauth_ui(ui, id = "auth", client = client) for query and query-JARM responses. The ID and client must match oauth_module_server(). The wrapper validates callbacks and stores sealed, short-lived responses, then redirects to a one-time bridge URL before invoking the application UI. State remains single-use and browser-bound. Missing bridge configuration rejects raw callbacks; existing oauth_ui(ui) setups must add id and client.

Callback and HTML responses send Cache-Control: no-store, Pragma: no-cache, and Referrer-Policy: no-referrer. These do not remove upstream access logs: configure proxies and hosting logs to omit callback queries. Keep third-party scripts off any unsanitized callback page, including when using use_shinyOAuth() with a custom HTTP integration. Register fixed tenant or routing query parameters in the redirect URI; unregistered inbound parameters are discarded on continuation. For trusted HTTPS-terminating proxies, use request_uri_resolver with the same trust checks as oauth_form_post_ui().

Provider key-set validation

shinyOAuth validates the whole fetched JWKS before selecting a key by kid, usage, operations, or algorithm. A malformed RSA, EC, or OKP public entry can therefore reject the set even when another entry would validate the token. This is a deliberately stricter availability policy than the recommendation to ignore individual unusable keys in RFC 7517 section 5. Providers must publish structurally valid public entries throughout key rotation; an unrelated broken entry can interrupt login, signed UserInfo, JARM, or Request Object encryption until the provider repairs its JWKS.

Unknown key types are ignored for selection after common structural checks. Malformed set structure, duplicate JSON members, and secret key material are rejected. With pinning mode "any", at least one supported public key must match a configured thumbprint, and selected keys must be pinned. Mode "all" requires every RSA, EC, and OKP entry in the set to have a computable, configured thumbprint, including entries that would later be filtered out. Neither mode skips the whole-set structural checks or the selected key’s strength checks.

Trusted ID-token audiences

ID tokens with multiple audiences remain rejected by default. If an issuer legitimately includes another trusted audience, configure trusted_id_token_audiences = c("trusted-service") on oauth_client(). The token must still include this client’s ID in aud, and azp must equal this client’s ID when present. Trusted multiple audiences do not require an azp claim under OIDC Core section 3.1.3.7. Other audiences, incorrect authorized parties, and invalid signatures are rejected.

Mutual TLS (mTLS)

With mutual TLS (mTLS), the client presents a certificate during the TLS connection. OAuth 2.0 uses this for certificate-based client authentication and for certificate-bound access tokens (RFC 8705). With certificate-bound tokens, the API requires the matching certificate when accepting a token. The provider must support the selected use of mTLS.

provider <- oauth_provider(
  name = "example-mtls",
  # Exact OIDC issuer; enables nonce and ID-token validation
  issuer = "https://id.example.com",
  auth_url = "https://id.example.com/authorize",
  token_url = "https://id.example.com/token",
  jwks_uri = "https://id.example.com/jwks",
  userinfo_url = "https://id.example.com/userinfo",
  # Use RFC 8705 client-certificate auth at the token endpoint
  token_auth_style = "tls_client_auth",
  # Use mTLS-specific endpoints when the provider publishes them
  mtls_endpoint_aliases = list(
    token_endpoint = "https://mtls.id.example.com/token",
    userinfo_endpoint = "https://mtls.id.example.com/userinfo"
  ),
  # Expect certificate-bound access tokens from the provider
  mtls_client_certificate_bound_access_tokens = TRUE
)
client <- oauth_client(
  provider = provider,
  client_id = "client-id",
  redirect_uri = "https://app.example.com/auth/callback",
  scopes = c("openid", "profile"),
  # Certificate and key sent on mTLS requests
  mtls_client_cert_file = "certs/client.pem",
  mtls_client_key_file = "certs/client-key.pem",
  mtls_client_ca_file = "certs/ca.pem",
  # Require the matching certificate when access tokens are used
  mtls_certificate_bound_access_tokens = TRUE
)

mtls_certificate_bound_access_tokens = TRUE enables certificate presentation and mTLS endpoint-alias selection independently of the OAuth client authentication method. Configuring certificate files alone does not enable certificate-bound token requests.

The separate mtls_require_observed_cnf policy defaults to TRUE, preserving strict local assurance: shinyOAuth must observe cnf[["x5t#S256"]] in the token response, a JWT access token, or introspection and match it to the configured certificate. An opaque bound token without observable binding fails this policy.

RFC 8705 also permits opaque tokens whose binding is known only to the authorization and resource servers. For that deployment, configure the certificate/key and provider capability as above, then use both settings:

client <- oauth_client(
  provider = provider,
  client_id = "client-id",
  redirect_uri = "https://app.example.com/auth/callback",
  mtls_client_cert_file = "certs/client.pem",
  mtls_client_key_file = "certs/client-key.pem",
  mtls_certificate_bound_access_tokens = TRUE,
  mtls_require_observed_cnf = FALSE
)

This mode still presents the certificate on token, refresh, UserInfo, and protected-resource requests, including with body authentication, public clients, or private_key_jwt. It prefers configured mTLS aliases and falls back to the configured endpoint URL when no alias is available. The servers must enforce binding; shinyOAuth allows missing confirmation but still rejects any observed mismatch or conflicting confirmation claims. See RFC 8705, Section 3.

On Windows, separate PEM certificate/key files require curl’s OpenSSL backend. Set CURL_SSL_BACKEND=openssl in .Renviron and restart R, or run Sys.setenv(CURL_SSL_BACKEND = "openssl") before loading curl, httr2, or shinyOAuth in a fresh session. Check curl::curl_version()[["ssl_version"]]: parenthesized backends are inactive alternatives. If OpenSSL is unavailable, install a curl build that provides it. shinyOAuth rejects an active Schannel backend for this PEM configuration before sending the request. See the libcurl certificate documentation.

If your provider uses dynamic client registration, oauth_client_mtls_registration() can build the RFC 8705 registration metadata from the configured client.

JWT-secured authorization request (JAR)

JAR protects authorization request parameters with a signature. The client sends them in a JWT called a Request Object, which the provider verifies before processing the request. Optional Request Object encryption also protects the request contents. Both settings must match the provider registration.

For enforced request integrity, configure the authorization server to require signed Request Objects for this client, for example with the RFC 9101 client registration field require_signed_request_object = true, and register its signing key and allowed algorithm. Confirm the server actually enforces this policy. Setting signed_request_object_required = TRUE below describes that server policy and enforces local construction; it does not register or change the client at the server. If unsigned requests remain accepted, signing is optional and cannot prevent a downgrade to unsigned authorization requests (RFC 9101 section 10.5).

provider <- oauth_provider(
  name = "example-jar",
  issuer = "https://id.example.com",
  auth_url = "https://id.example.com/authorize",
  token_url = "https://id.example.com/token",
  # The server registration must already require signed Request Objects
  signed_request_object_required = TRUE,
  request_parameter_supported = TRUE,
  request_object_signing_alg_values_supported = c("RS256")
)

client <- oauth_client(
  provider = provider,
  client_id = "client-id",
  client_secret = "client-secret",
  redirect_uri = "https://app.example.com/auth/callback",
  scopes = c("openid", "profile"),
  # Signing key for the Request Object
  client_assertion_private_key = openssl::read_key("keys/client-key.pem"),
  # Send the authorization request as a JWT in the request parameter
  request_object_mode = "request",
  request_object_signing_alg = "RS256"
)

Test the server policy before deployment: send an otherwise valid authorization request for this same client without request or request_uri. For example:

unsigned <- httr2::request(provider@auth_url) |>
  httr2::req_url_query(
    client_id = client@client_id,
    redirect_uri = client@redirect_uri,
    response_type = "code", scope = "openid profile",
    state = "unsigned-policy-probe",
    code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
    code_challenge_method = "S256"
  ) |>
  httr2::req_options(followlocation = FALSE) |>
  httr2::req_error(is_error = function(resp) FALSE) |>
  httr2::req_perform()
httr2::resp_status(unsigned)
httr2::resp_headers(unsigned)
httr2::resp_body_string(unsigned)

The negative test passes only when the server explicitly rejects the request because the required signed Request Object is missing. Check its documented error response or server audit event for that reason; a generic HTTP error is insufficient. A login/consent page, authorization code, or login_required response does not establish enforcement. Repeat this probe in deployment tests alongside a successful signed request.

Register the signing key with your provider. To encrypt the signed request too, configure request_object_encryption_alg = "RSA-OAEP" and request_object_encryption_enc to a supported AES-CBC-HMAC value such as "A128CBC-HS256". The provider must publish a suitable encryption key or you must supply request_object_encryption_jwk to oauth_provider(). See oauth_client() for supported algorithms and the provider reference for encryption key selection.

PAR, described below, keeps most request details out of the browser URL. It can also carry a signed Request Object, combining PAR with JAR.

Request Objects published by the Shiny app

If you set request_object_mode = "request_uri", shinyOAuth still builds a signed Request Object, but instead of putting that JWT directly on the browser redirect as request=..., it publishes the Request Object at a URL and sends the provider request_uri=<that URL>. The provider then fetches that published Request Object itself.

oauth_module_server() serves the Request Object at a short-lived URL under the Shiny app. The provider must be able to request that URL directly.

This mode is separate from PAR and cannot be used when the provider requires PAR. With PAR, the provider issues the reference; with this mode, the provider fetches a URL published by your app.

Deployment requirements:

request_uri_provider <- oauth_provider(
  name = "example-request-uri",
  issuer = "https://id.example.com",
  auth_url = "https://id.example.com/authorize",
  token_url = "https://id.example.com/token",
  request_uri_parameter_supported = TRUE,
  request_object_signing_alg_values_supported = "RS256"
)

client <- oauth_client(
  provider = request_uri_provider,
  client_id = "client-id",
  client_secret = "client-secret",
  redirect_uri = "https://app.example.com/auth/callback",
  scopes = c("openid", "profile"),
  client_assertion_private_key = openssl::read_key("keys/client-key.pem"),
  # Publish the Request Object by reference instead of sending it inline
  request_object_mode = "request_uri",
  request_object_signing_alg = "RS256"
)

# Inside server()
auth <- oauth_module_server(
  "auth",
  client,
  auto_redirect = TRUE,
  # Public HTTPS base URL of this Shiny app as seen by the provider
  request_uri_base_url = "https://shiny.yourdomain.com/myapp"
)

Wrap the app UI in oauth_ui(ui, id = "auth", client = client) (or oauth_form_post_ui() for POST callbacks). The app root serves Request Objects using a random, single-purpose shinyOAuth_request_object query handle. Only its digest is stored alongside the object in client@state_store. GET consumes it atomically; HEAD does not consume it. Retrieval expires at the earlier of the object expiry and 120 seconds after publication. Cache eviction can shorten availability; the store’s retention policy controls physical cleanup. If the provider requires request_uri registration, update its permitted URLs to cover this app-root endpoint when migrating from the old /session/ paths. For multiple workers, use a shared state store with atomic take(); a memory store works only when publication and retrieval reach the same R process.

The hosted JWT and its expiry occupy a separate store record, outside the pending-login record’s state_key sealing. Signing leaves JWT claims readable; enable Request Object JWE encryption for claim confidentiality. See the custom_cache() shared-store contract for record protection and expiry requirements.

Earlier versions used Shiny’s raw live session token in registerDataObj() URLs. Those URLs could disclose a capability also used by other session resources through browser, provider, or proxy logs. The new handles contain no session token. Still redact request URLs in logs and limit log retention: disclosing a live handle allows its holder to consume the pending object. Prefer PAR when supported to use a provider-issued reference and avoid a public app retrieval endpoint. OIDC signed requests retain outer client_id, response_type, and scope parameters. authorization_request_front_channel_mode = "minimal" is available for compatible PAR providers, but is rejected for OIDC inline or client-published signed requests.

Pushed authorization requests (PAR)

PAR sends the authorization request from your server to the provider first. The browser then gets redirected with a short request_uri handle instead of the full request details.

PAR allows the provider to validate the request before the browser redirect and avoids placing large requests in a URL. It also keeps most request details out of browser history and logs of browser requests. Set par_required = TRUE when the provider requires PAR.

provider <- oauth_provider(
  name = "example-par",
  issuer = "https://id.example.com",
  auth_url = "https://id.example.com/authorize",
  token_url = "https://id.example.com/token",
  # Enable pushed authorization requests
  par_url = "https://id.example.com/par",
  par_required = TRUE,
  # Keep the browser redirect down to client_id + PAR request_uri
  authorization_request_front_channel_mode = "minimal"
)

client <- oauth_client(
  provider = provider,
  client_id = "client-id",
  client_secret = "client-secret",
  redirect_uri = "https://app.example.com/auth/callback",
  scopes = c("openid", "profile")
)

Form Post response mode

response_mode = "form_post" tells the provider to send the authorization response back as an HTTP POST body instead of query parameters on the URL. The body still contains normal OAuth fields such as code, state, error, and iss.

Use form_post when required by the provider or to keep callback values out of the browser URL, history, and logs of browser requests. It changes the callback transport; it does not sign or encrypt the response.

Keep your existing provider and credentials, set response_mode = "form_post" on the client, then use this UI setup. Here client has a registered redirect_uri such as https://app.example.com/callback.

base_ui <- shiny::fluidPage(shiny::textOutput("status"))
ui <- oauth_form_post_ui(base_ui, id = "auth", client = client)

server <- function(input, output, session) {
  auth <- oauth_module_server("auth", client)
  output[["status"]] <- shiny::renderText({
    if (isTRUE(auth[["authenticated"]])) "Signed in" else "Waiting for login"
  })
}

app <- shiny::shinyApp(ui, server, uiPattern = ".*")

The wrapper includes oauth_ui() setup. Its module ID and client must match the server’s. For a callback path such as /callback, uiPattern = ".*" lets Shiny send the POST to the wrapper. A callback at the app root also works. callback_path defaults to the path in redirect_uri; keep both aligned.

Deployment behind an HTTPS proxy

If your web server accepts HTTPS but forwards HTTP to Shiny, the wrapper needs a trusted way to recover the public request address. Configure request_uri_resolver for your own proxy. This example accepts one proxy IP and a fixed public origin, including a mounted app path:

trusted_proxy_uri <- function(req) {
  if (!identical(req[["REMOTE_ADDR"]], "10.0.0.10") ||
      !identical(req[["HTTP_X_FORWARDED_PROTO"]], "https")) {
    return(NULL)
  }
  paste0("https://app.example.com", req[["SCRIPT_NAME"]], req[["PATH_INFO"]])
}

ui <- oauth_form_post_ui(
  base_ui, id = "auth", client = client,
  request_uri_resolver = trusted_proxy_uri
)

Use your deployment’s verified proxy address and public origin. The result must still match the configured redirect origin and callback path. Do not trust forwarded headers from arbitrary clients.

JWT-secured authorization response mode (JARM)

JARM protects the authorization response with a signature. The provider returns a JWT, and shinyOAuth verifies its signature, issuer, audience, and expiry before processing the callback fields. If encryption is configured, shinyOAuth decrypts the response before validating the signed contents.

provider <- oauth_provider(
  name = "example-jarm",
  issuer = "https://id.example.com",
  auth_url = "https://id.example.com/authorize",
  token_url = "https://id.example.com/token",
  # Advertise the JARM response modes and algorithms this provider supports
  response_modes_supported = c("query", "query.jwt", "form_post.jwt"),
  jarm_signing_alg_values_supported = c("RS256"),
  jarm_encryption_alg_values_supported = c("RSA-OAEP"),
  jarm_encryption_enc_values_supported = c("A128CBC-HS256")
)

client <- oauth_client(
  provider = provider,
  client_id = "client-id",
  client_secret = "client-secret",
  redirect_uri = "https://app.example.com/auth/callback",
  scopes = c("openid", "profile"),
  # Ask for a JWT-wrapped authorization response
  response_mode = "query.jwt",
  jarm_signed_response_alg = "RS256"
)

For encrypted JARM, add the decryption settings:

client <- oauth_client(
  provider = provider,
  client_id = "client-id",
  client_secret = "client-secret",
  redirect_uri = "https://app.example.com/auth/callback",
  scopes = c("openid", "profile"),
  response_mode = "query.jwt",
  jarm_signed_response_alg = "RS256",
  # Optional: decrypt JARM before validating the signed payload
  jarm_encrypted_response_alg = "RSA-OAEP",
  jarm_encrypted_response_enc = "A128CBC-HS256",
  jarm_decryption_private_key = openssl::read_key("keys/jarm-decrypt.pem")
)

JARM is currently intended for oauth_module_server(). If you use response_mode = "form_post.jwt", wrap your UI with oauth_form_post_ui().

Demonstrating proof-of-possession (DPoP)

DPoP binds tokens to a client key. The client signs a proof for each token or API request, and the receiving server verifies it against the token binding. An API enforcing DPoP requires both the access token and a proof from the matching private key. Configure it when supported by the authorization server and the API.

provider <- oauth_provider(
  name = "example-dpop",
  issuer = "https://id.example.com",
  auth_url = "https://id.example.com/authorize",
  token_url = "https://id.example.com/token",
  # Optional metadata check for acceptable DPoP signing algorithms
  dpop_signing_alg_values_supported = c("ES256")
)

client <- oauth_client(
  provider = provider,
  client_id = "client-id",
  client_secret = "client-secret",
  redirect_uri = "https://app.example.com/auth/callback",
  scopes = c("openid", "profile", "api.read"),
  # Private key used to sign DPoP proofs
  dpop_private_key = openssl::read_key("keys/dpop-key.pem"),
  dpop_signing_alg = "ES256"
)

After login, keep using the request helpers instead of adding Authorization or DPoP headers manually:

resp <- perform_resource_req(
  auth[["token"]],
  "https://api.example.com/me",
  # Lets shinyOAuth attach the DPoP proof and handle nonce challenges
  client = client
)

Token binding requirements and validation

Supplying a DPoP key makes dpop_require_access_token default to TRUE: the provider must return a DPoP access token. If binding data is visible, its cnf[["jkt"]] key thumbprint must match. For opaque tokens with no visible binding, enable dpop_require_observed_cnf = TRUE and arrange introspection if your deployment needs to confirm that binding locally.

For certificate-bound tokens, the corresponding field is cnf[["x5t#S256"]]. The package checks it against the configured certificate before protected API and userinfo calls. A refreshed token needs fresh binding data from the new token or its introspection response; the old certificate thumbprint is not carried forward when the response omits it.

Binding data read from a JWT access token is observed payload data; shinyOAuth does not independently verify that access token’s signature. Introspection can provide confirmation from the provider. The API must enforce the binding too for a stolen token to be unusable without its key or certificate.

DPoP in API requests

perform_resource_req() and get_userinfo() handle a DPoP nonce challenge with one fresh-proof retry. Later requests to the same resource server can reuse its nonce; token-server and resource-server nonces are kept separate. Retries of eligible API requests generate fresh proofs.

DPoP nonces must follow RFC 9449’s visible ASCII syntax. The package also applies a local 4096-byte limit to bound proof and cache sizes; the RFC itself sets no maximum length. Configure options(shinyOAuth.dpop_nonce_max_bytes = 8192L) for a provider issuing larger nonces (supported range: 1–65536 bytes). A response above the configured limit raises an explicit error without logging its nonce.

resource_req() only builds the request. A DPoP proof is tied to its HTTP method and base URL, so do not change those after construction. Supply query parameters through the helper’s query argument; external URL modifiers can decode reserved path characters and invalidate the proof. Use perform_resource_req() to manage nonce retries.

Signature and encryption support

For outgoing private-key client assertions, JAR, and DPoP, signing supports RS256, RS384, ES256, ES384, ES512, and both EdDSA and Ed25519 with Ed25519 keys. Ed25519 accepts an OpenSSL private key or PEM; DPoP embeds only its public OKP JWK. Algorithm inference and explicit choices remain constrained by provider metadata. Secret-based assertions and JAR support HS256, HS384, and HS512. RSA continues to default to RS256; choose client_assertion_alg = "RS384" explicitly for a registration requiring it. RS384 uses SHA-384 with RSASSA-PKCS1-v1_5 and requires an RSA key of at least 2048 bits, as specified in RFC 7518 section 3.3. RSA-PSS and Ed448 are not supported for outgoing signatures. Incoming signature policies are separate; see oauth_provider() and the jarm_* arguments in oauth_client().

Request Object encryption and JARM decryption support RSA-OAEP with A128CBC-HS256, A192CBC-HS384, or A256CBC-HS512. This does not imply support for encrypted ID tokens or encrypted userinfo, which are rejected.

The R openssl API currently exposes neither OAEP digest selection nor RSA-PSS padding options. Its GCM functions do not accept the additional authenticated data needed to authenticate JWE protected headers. Consequently, RSA-OAEP-256, PS256/384/512, and A128/192/256GCM JWE modes remain unsupported and fail closed; provider metadata never causes a fallback to another algorithm. These are backend interoperability limits, not interchangeable names for the supported modes. See the OpenSSL R API.