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.
Discovery reads the provider’s published settings so you do not have to copy each endpoint and supported feature manually:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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().
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.
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.
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().