Advanced security configuration

Overview

Some login providers require extra proof that a request comes from your app, or extra protection for the tokens they issue. Start with the working app in Usage, then configure the features your provider requires.

A signature lets the receiver check who sent a message and whether it changed. Encryption hides its contents. A client certificate or signing key can also tie an access token to your app, so the token alone is not enough to use it.

The features below depend on support from the provider and, for token binding, the API.

Need Feature
Prove the app’s identity with a signing key JWT client authentication
Present a registered certificate Mutual TLS
Send request details directly to the provider PAR
Sign the login request JAR
Return login results outside the URL or with a signature Form Post or JARM
Require a matching key when using a token DPoP

The examples use placeholder addresses and key files for the app registration.

Provider metadata and OIDC discovery

Discovery reads the provider’s published settings so you do not have to copy each endpoint and supported feature manually:

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

JWT client authentication

With private_key_jwt, your app signs a short-lived JSON Web Token (JWT) when requesting tokens instead of sending a shared secret. Register the public key with the provider, then configure the matching private key:

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"
)

The provider uses the public key to verify the app’s message. For secret-based assertions, see oauth_client(). See endpoint-specific authentication for credentials and audience settings.

Mutual TLS (mTLS)

mTLS lets your app present a certificate during the secure network connection. It can identify the app to the provider. Separately, a provider can issue certificate-bound tokens that an API accepts only with the matching certificate. Configure the use agreed with your provider:

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
)

By default, shinyOAuth requires visible confirmation of the token’s certificate binding and verifies it. If your provider uses opaque tokens, see the certificate-binding details below.

On Windows, separate PEM certificate and key files require curl’s OpenSSL backend. Follow the platform setup in that section before testing mTLS.

Pushed authorization requests (PAR)

PAR sends login-request details directly from your server to the provider. The browser carries a short reference instead of the full request, which keeps most details out of browser history and avoids very long URLs.

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")
)

JWT-secured authorization request (JAR)

JAR signs the login request so the provider can detect changes to its parameters. It can be combined with PAR. The app’s provider registration holds the public signing key and the requirement for signed requests; shinyOAuth’s local setting controls request construction.

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"
)

The unsigned-request example below illustrates the provider-side enforcement check.

If your provider fetches signed requests from the app, see Request Objects published by the Shiny app.

Form Post response mode

Form Post returns the login result in an HTTP request body instead of the browser URL, keeping callback values out of browser history. Signing and encryption are separate features.

Set response_mode = "form_post" on the client, then use the matching wrapper:

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 = ".*")

uiPattern = ".*" allows Shiny to serve the dedicated callback path.

If your proxy accepts HTTPS but forwards HTTP to Shiny, the wrapper needs a trusted way to recover the public callback address. See the HTTPS proxy example below.

JWT-secured authorization response mode (JARM)

JARM signs the returning login result. shinyOAuth verifies it before accepting the callback fields, giving the app a way to check the response’s sender and integrity. Select the mode and signing algorithm supported by your registration:

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"
)

JARM uses oauth_module_server(). With form_post.jwt, also use oauth_form_post_ui(). Optional response encryption requires additional client settings and support for local R subprocesses; see the encrypted response example below.

Demonstrating proof-of-possession (DPoP)

DPoP ties an access token to a signing key. Each request includes fresh proof that the app holds that key, so an API enforcing DPoP cannot accept a stolen token on its own. The provider and the API must both support it.

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, let the request helper attach the proof and handle provider nonce challenges:

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

The helper uses client = client to supply the matching key or certificate. For DPoP tokens it rejects automatic redirects, since the proof is tied to the request’s method and address.

Browser binding and deployment boundary

The module links a pending login to browser session storage and a temporary cookie. Callback validation checks both against the initiating transaction; browser setup and binding rotation happen automatically through the UI wrapper.

Session storage is scoped to the app’s origin and tab. Cookies are shared across ports on a hostname, and scripts on the app’s origin can access its storage.

With oauth_ui() or oauth_form_post_ui(), callbacks are processed before application UI loads. Low-level integrations built directly on prepare_call() and handle_callback() supply their own browser-binding and HTTP handling.

Endpoint-specific authentication

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

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

RFC7523bis draft 11 section 4 describes these settings. They apply to client authentication assertions, not JAR, JARM or ID token headers. Match them to your provider’s registration.

PAR, introspection and revocation can each require different authentication:

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 settings. Each endpoint has its own authentication method and audience. extra_token_headers applies only to exchange and refresh; extra_headers covers other endpoints.

Certificate binding and platform requirements

mtls_certificate_bound_access_tokens = TRUE enables certificate presentation and mTLS endpoint selection, independently of the client authentication method.

By default, mtls_require_observed_cnf = TRUE requires visible binding data that matches the certificate. Some providers use opaque tokens whose binding only their servers can check. mtls_require_observed_cnf = FALSE permits missing local confirmation for opaque tokens:

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
)

Any observed certificate mismatch is rejected. The provider and API enforce binding; 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 before loading curl, httr2 or shinyOAuth. Check the active backend with curl::curl_version()[["ssl_version"]]; shinyOAuth rejects Schannel for this configuration. See the curl certificate guide.

For dynamic registration, oauth_client_mtls_registration() builds the matching certificate registration metadata.

Testing signed-request enforcement

This request uses the same client registration but omits the signed Request Object:

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)

Provider enforcement means this otherwise valid request is rejected for the missing signature while a signed request succeeds. An HTTP error, login page or login_required response alone leaves that result unresolved.

To encrypt the signed request, select supported request_object_encryption_alg and request_object_encryption_enc values, and configure a provider encryption key. See oauth_client() and oauth_provider(). PAR can also carry the signed object while keeping most details out of the URL.

Request Objects published by the Shiny app

With request_object_mode = "request_uri", shinyOAuth publishes the signed request at a short-lived URL for the provider to fetch. This differs from PAR, where the provider issues the reference. Providers requiring PAR cannot use app-hosted Request Objects in its place.

The provider must be able to reach the public HTTPS URL. Register it or its prefix if your provider requires this:

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"
)

Use oauth_ui() or oauth_form_post_ui() with the same client and state store as the server. Multiple R processes need a shared store with atomic take(); see custom_cache().

A live retrieval handle permits one-time consumption of the object. Signing leaves its claims readable; Request Object encryption supplies confidentiality.

The "minimal" front-channel mode is for compatible PAR providers; it is not available for OIDC inline or app-hosted signed requests.

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
)

This resolver accepts forwarded metadata from the configured proxy address. The wrapper checks the result against the registered redirect origin and callback path.

Encrypted authorization responses

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")
)

Use oauth_module_server() and, for form_post.jwt, oauth_form_post_ui(). Encrypted callbacks use a temporary local R process, so hosting must permit subprocesses and allow for startup time.

Token binding requirements and validation

With a DPoP key configured, dpop_require_access_token defaults to TRUE. The provider must return a DPoP token, and any visible key binding must match. dpop_require_observed_cnf = TRUE also requires local confirmation; opaque tokens may need introspection to provide it.

Certificate-bound tokens similarly need matching certificate data. Refresh must provide fresh binding data; shinyOAuth does not reuse the old certificate thumbprint when the new response omits it. Malformed binding data is rejected.

Reading binding data from a JWT access token does not verify that token’s signature. Introspection can confirm it with the provider. The API must also enforce binding for a stolen token to be unusable without its key or certificate.

DPoP in API requests

perform_resource_req() and get_userinfo() handle a nonce challenge with one fresh-proof retry. Proofs bind the request’s method and address, and the helpers reject automatic redirects. The query argument preserves the URL form used when signing.

perform_resource_req() also applies decoded response-size limits. Direct httr2::req_perform() uses httr2’s transport instead. If your provider issues unusually large nonces, see shinyOAuth.dpop_nonce_max_bytes in package options.

Policy identity across callbacks and retained credentials

Pending logins and saved connections remember the client and provider policies that created them. Policy changes invalidate those records. After upgrades that change the policy encoding, users must sign in again; update workers that share keys and storage together.

If a custom userinfo_id_selector depends on external settings, assign a userinfo_id_selector_policy_id and change it when those settings or helper behavior change. This identifies policy inputs outside the function body. The Spotify helper tracks its allow_legacy_id setting automatically.

Issuer comparison and transport policy

compare_callback_issuer = TRUE compares iss exactly with the trusted provider@issuer; enforce_callback_issuer also requires it to be present. Providers advertising RFC 9207 support require the identifier, while validated JARM supplies its own issuer. Apps using several providers still need issuer identification or distinct callback routes.

For legacy providers that omit iss, comparison can remain enabled while presence is optional. The flags control these checks separately; see oauth_client().

Package HTTPS requests require TLS 1.2 or later by default. options(shinyOAuth.tls_min_version = "1.3") selects a higher minimum; set it before discovery or login. Browser and proxy TLS need separate configuration. See package options.

Callback pages and application scripts

For query and query-JARM callbacks, oauth_ui(ui, id = "auth", client = client) processes callbacks before loading application UI and sets privacy headers. Its ID and client identify the corresponding oauth_module_server() instance.

Response headers control browser caching and referrers, independently of upstream hosting and proxy logs. The standard UI wrappers isolate raw callbacks from application scripts; custom use_shinyOAuth() integrations provide their own HTTP callback handling.

The registered redirect URI declares fixed routing query parameters; continuation discards unregistered inbound parameters. Behind an HTTPS proxy, request_uri_resolver supplies the public address under the trust policy described for oauth_form_post_ui().

Provider key-set validation

shinyOAuth checks the entire provider key set before selecting a signing key. A malformed entry can therefore interrupt login or signed responses even if another key would work. Providers must publish valid entries throughout key rotation; see RFC 7517 section 5 for the standard’s less restrictive recommendation.

shinyOAuth rejects conflicting signing algorithms for one RSA key, including when reused under different key IDs. Key rotation changes the key and can require corresponding pin, policy and cache updates; changing only kid does not change the key. See oauth_provider() for pinning and key-selection settings.

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(). shinyOAuth still requires this client’s ID in aud and a matching azp when present. Trusted multiple audiences do not require an azp claim under OIDC Core section 3.1.3.7.

Signature and encryption support

The signing algorithm must be supported by both the provider and shinyOAuth. Outgoing defaults depend on the key: RSA defaults to RS256, with client_assertion_alg = "RS384" available for registrations requiring RS384.

Use Supported algorithms
Private-key assertions, JAR and DPoP RS256, RS384, ES256, ES384, ES512, and EdDSA or Ed25519 with Ed25519 keys.
Secret-based assertions and JAR HS256, HS384, HS512.
Request Object encryption and JARM decryption RSA-OAEP with A128CBC-HS256, A192CBC-HS384 or A256CBC-HS512.

Outgoing RSA-PSS and Ed448 signatures are unsupported. For encryption, RSA-OAEP-256 and GCM JWE modes are unsupported, as are encrypted ID tokens and encrypted UserInfo.

Incoming signature policies are separate. See oauth_provider() and the jarm_* settings in oauth_client().