‘shinyOAuth’ helps a Shiny app send users to an OAuth 2.0 or OpenID Connect (OIDC) provider, handle the return callback, and keep the flow secure by default. It takes care of:
For a full step-by-step protocol breakdown, see the separate
vignette:
vignette("authentication-flow", package = "shinyOAuth").
For a detailed explanation of audit logging key events during the
flow, see:
vignette("audit-logging", package = "shinyOAuth").
For a dedicated description of OpenTelemetry support in ‘shinyOAuth’,
see: vignette("opentelemetry", package = "shinyOAuth").
Below is a minimal example using a GitHub OAuth app (the same setup
shown in the README). Register an OAuth 2.0 application at https://github.com/settings/developers and set
environment variables GITHUB_OAUTH_CLIENT_ID and
GITHUB_OAUTH_CLIENT_SECRET.
library(shiny)
library(shinyOAuth)
provider <- oauth_provider_github()
client <- oauth_client(
provider = provider,
client_id = Sys.getenv("GITHUB_OAUTH_CLIENT_ID"),
client_secret = Sys.getenv("GITHUB_OAUTH_CLIENT_SECRET"),
redirect_uri = "http://127.0.0.1:8100",
scopes = c("read:user", "user:email")
)
ui <- fluidPage(
# Include JavaScript dependency:
use_shinyOAuth(),
# Render login status & user info:
uiOutput("login")
)
server <- function(input, output, session) {
auth <- oauth_module_server("auth", client, auto_redirect = TRUE)
output$login <- renderUI({
if (auth$authenticated) {
user_info <- auth$token@userinfo
tagList(
tags$p("You are logged in!"),
tags$pre(paste(capture.output(str(user_info)), collapse = "\n"))
)
} else {
tags$p("You are not logged in.")
}
})
}
runApp(
shinyApp(ui, server),
port = 8100,
launch.browser = FALSE
)
# Open the app in your regular browser at http://127.0.0.1:8100
# (viewers in RStudio/Positron/etc. cannot perform necessary redirects)use_shinyOAuth() must be included once in your UI. It
loads the JavaScript helper that the login flow depends on. Place it
near the top of your UI, for example inside fluidPage(),
tagList(), or bslib::page().
Open the app in a regular browser, not an IDE viewer. Embedded viewers in tools like RStudio or Positron usually cannot complete the required redirects.
After login succeeds, you can use the access token to call an API on
the user’s behalf. perform_resource_req() is the easiest
option for most call sites: it builds an authorized httr2
request, performs it, and when the token type is DPoP it
also handles a one-time DPoP-Nonce challenge retry. Use
resource_req() when you need to inspect or customize the
httr2 request before sending it yourself.
The example below calls the GitHub API to fetch the user’s repositories.
library(shiny)
library(shinyOAuth)
provider <- oauth_provider_github()
client <- oauth_client(
provider = provider,
client_id = Sys.getenv("GITHUB_OAUTH_CLIENT_ID"),
client_secret = Sys.getenv("GITHUB_OAUTH_CLIENT_SECRET"),
redirect_uri = "http://127.0.0.1:8100",
scopes = c("read:user", "user:email")
)
ui <- fluidPage(
use_shinyOAuth(),
uiOutput("ui")
)
server <- function(input, output, session) {
auth <- oauth_module_server(
"auth",
client,
auto_redirect = TRUE
)
repositories <- reactiveVal(NULL)
observe({
req(auth$authenticated)
# Example additional API request using the access token
# (e.g., fetch user repositories from GitHub)
resp <- perform_resource_req(
auth$token,
"https://api.github.com/user/repos"
)
if (httr2::resp_is_error(resp)) {
repositories(NULL)
} else {
repos_data <- httr2::resp_body_json(resp, simplifyVector = TRUE)
repositories(repos_data)
}
})
# Render username + their repositories
output$ui <- renderUI({
if (isTRUE(auth$authenticated)) {
user_info <- auth$token@userinfo
repos <- repositories()
return(tagList(
tags$p(paste("You are logged in as:", user_info$login)),
tags$h4("Your repositories:"),
if (!is.null(repos)) {
tags$ul(
Map(function(url, name) {
tags$li(tags$a(href = url, target = "_blank", name))
}, repos$html_url, repos$full_name)
)
} else {
tags$p("Loading repositories...")
}
))
}
return(tags$p("You are not logged in."))
})
}
runApp(
shinyApp(ui, server),
port = 8100,
launch.browser = FALSE
)
# Open the app in your regular browser at http://127.0.0.1:8100
# (viewers in RStudio/Positron/etc. cannot perform necessary redirects)For an example application which fetches data from the Spotify web
API, see:
vignette("example-spotify", package = "shinyOAuth").
By default, oauth_module_server() performs network
operations (authorization-code exchange, refresh, userinfo) on the main
R thread. That keeps setup simple, but a slow provider or retry delay
can temporarily block the Shiny worker handling the session.
To avoid blocking, enable async mode and configure an async backend.
‘shinyOAuth’ supports both mirai and future
and auto-detects whichever one you have configured. If both are set up,
mirai takes precedence.
For the future backend, use a non-sequential plan such
as future::multisession() or
future::multicore() where available.
future::sequential() still runs in the same R process, so
it does not move network work off the main R thread.
If you need to keep async = FALSE, you may consider
reducing retry behaviour to limit blocking during provider incidents.
See the global options section for timeout and retry settings.
# Set up daemons at the top of your app (or in global.R)
mirai::daemons(2)
# Clean up daemons when the app stops
onStop(function() mirai::daemons(0))
server <- function(input, output, session) {
auth <- oauth_module_server(
"auth",
client,
auto_redirect = TRUE,
async = TRUE # Run token exchange & refresh off the main thread
)
# ...
}To log out the user, call auth$logout(). This clears the
local session, sets auth$error to
"logged_out", reissues a fresh browser token for the next
login attempt, and attempts to revoke tokens at the provider (if a
revocation endpoint is available):
response_mode = "form_post"The response mode determines how the provider returns the
authorization response to the app after the user authenticates. The
effective default is the normal query callback flow, which means the
provider redirects back to the app with query parameters (e.g.,
?code=...&state=...) and shinyOAuth does not send a
response_mode parameter unless you configure one.
For most Shiny apps, query is the preferred response mode because it
works seamlessly with Shiny’s routing and does not require any special
UI handling. It is the default and does not require setting
response_mode explicitly.
For some apps, when your provider explicitly requires or recommends
response_mode = "form_post", you can configure that on the
client. Because Shiny apps do not handle POST callbacks by default, you
need to enable this by wrapping your UI with
oauth_form_post_ui(). This allows the provider to POST the
authorization response back to the app. That wrapper also injects the
shinyOAuth browser dependency automatically, so you do not need a
separate use_shinyOAuth() call in the wrapped UI. The
/callback path below is only an example sub-route; using
the app root is also fine as long as the provider redirect URI matches
the path handled by oauth_form_post_ui(). Here’s how you
can set it up:
This is the plain OAuth/OIDC Form Post Response Mode: the POST body
contains parameters such as code, state,
error, and iss. JWT Secured Authorization
Response Mode (JARM) values such as form_post.jwt use a
different JWT response payload and are not currently
supported.
library(shiny)
library(shinyOAuth)
provider <- oauth_provider_keycloak(
base_url = "http://localhost:8080",
realm = "shinyoauth"
)
client <- oauth_client(
provider = provider,
client_id = "shiny-public",
client_secret = "",
# `/callback` is only an example sub-route. The app root also works if the
# provider redirect URI matches the path handled by `oauth_form_post_ui()`
redirect_uri = "http://127.0.0.1:8100/callback",
scopes = c("openid", "profile", "email"),
response_mode = "form_post"
)
base_ui <- fluidPage(
uiOutput("login")
)
ui <- oauth_form_post_ui(base_ui, id = "auth", client = client)
server <- function(input, output, session) {
auth <- oauth_module_server("auth", client, auto_redirect = TRUE)
output$login <- renderUI({
if (auth$authenticated) {
tagList(
tags$p("You are logged in!"),
tags$pre(paste(capture.output(str(auth$token@userinfo)), collapse = "\n"))
)
} else {
tags$p("You are not logged in.")
}
})
}
runApp(
shinyApp(ui, server, uiPattern = ".*"),
port = 8100,
launch.browser = FALSE
)
# Open the app in your regular browser at http://127.0.0.1:8100
# (viewers in RStudio/Positron/etc. cannot perform necessary redirects)If your redirect_uri is the app root (like
http://127.0.0.1:8100), uiPattern = ".*" is
usually harmless. If your redirect_uri is a sub-route (like
http://127.0.0.1:8100/callback), use
uiPattern = ".*" so Shiny routes that POST request through
oauth_form_post_ui() before the app returns to its normal
GET flow.
The package provides several global options to customize behavior. Most apps can stay with the defaults; this section is mainly for cases where you want to tune logging, networking, or a specific advanced behavior.
options(shinyOAuth.audit_hook = function(event){ ... })
– receive structured audit and error eventsoptions(shinyOAuth.audit_include_http = FALSE) –
exclude HTTP request details from audit events (default:
TRUE)options(shinyOAuth.audit_redact_http = FALSE) – disable
automatic redaction of sensitive data in audit events (default:
TRUE). Debug only: raw mode can expose cookies,
authorization headers, codes, state values, and client IP addressesoptions(shinyOAuth.audit_digest_key = ...) – shared key
for HMAC-SHA256 digests used in audit/OTel attributes. By default,
‘shinyOAuth’ generates a random per-process key when this is not
configuredoptions(shinyOAuth.otel_tracing_enabled = FALSE) –
disable ‘shinyOAuth’ OpenTelemetry span creation and async trace-context
propagation. Default: TRUEoptions(shinyOAuth.otel_logging_enabled = FALSE) –
disable ‘shinyOAuth’ OpenTelemetry log emission. Default:
TRUESee vignette("audit-logging", package = "shinyOAuth")
for details about audit hooks, and
vignette("opentelemetry", package = "shinyOAuth") for more
details about logs and traces via OpenTelemetry.
options(shinyOAuth.leeway = 30) – default clock skew
leeway (seconds) for ID token
exp/iat/nbf checks and state
payload issued_at future checkoptions(shinyOAuth.max_id_token_lifetime = 86400) –
maximum allowed ID token lifetime in seconds (exp - iat).
Tokens whose lifetime exceeds this cap are rejected (OIDC Core §3.1.3.7
rule 9). Default 86400 (24 hours). Set to Inf
to disable the checkoptions(shinyOAuth.allowed_non_https_hosts = c("localhost", "127.0.0.1", "::1", "[::1]"))
- allows hosts to use http:// scheme instead of
https://options(shinyOAuth.allowed_hosts = c()) – when
non‑empty, restricts accepted hosts to this whitelistoptions(shinyOAuth.allow_hs = TRUE) – opt‑in HMAC
validation for ID tokens (HS256/HS384/HS512). Requires a strictly
server‑side client_secretoptions(shinyOAuth.client_assertion_ttl = 120L) –
lifetime in seconds for JWT client assertions used with
client_secret_jwt or private_key_jwt token
endpoint authentication. Finite values below 60 seconds are coerced to
60 seconds, finite values above 300 seconds are clamped to 300 seconds,
and NA or non-finite values fall back to the 120-second
defaultoptions(shinyOAuth.state_fail_delay_ms = c(10, 30)) –
adds a small randomized delay (in milliseconds) before any state
validation failure (e.g., malformed token, IV/tag/ciphertext issues, or
GCM authentication failure). This helps reduce timing side‑channels
between different failure modesNote on allowed_hosts: patterns support globs
(*, ?). Using a catch‑all like
"*" matches any host and effectively disables endpoint host
restrictions (scheme rules still apply). Avoid this unless you truly
intend to accept any host; prefer pinning to your domain(s), e.g.,
c(".example.com").
Most users can ignore this section. By default, ‘shinyOAuth’ blocks
certain security-critical parameters from being passed via
extra_auth_params, extra_token_params, and
extra_token_headers. This helps prevent accidental
misconfiguration that could break state binding, PKCE, or client
authentication.
response_mode now has a dedicated client argument via
oauth_client(..., response_mode = ...). Prefer that
first-class API over setting
extra_auth_params$response_mode manually.
If you have a specific, advanced use case where you need to override one of these blocked parameters, you can unblock them using the following options:
options(shinyOAuth.unblock_auth_params = c("redirect_uri"))
– allows overriding the specified authorization URL parameters. Default
blocked: response_type, client_id,
redirect_uri, state, request_uri,
request, scope, code_challenge,
code_challenge_method, nonce,
claimsrequest and request_uri stay blocked by
default because ‘shinyOAuth’ manages them internally for PAR and Request
Object flows; leave them reserved unless you are intentionally taking
responsibility for a fully custom advanced flow.options(shinyOAuth.unblock_token_params = c(...)) –
allows overriding the specified token exchange parameters. Default
blocked: grant_type, code,
redirect_uri, code_verifier,
client_id, client_secret,
client_assertion, client_assertion_typeoptions(shinyOAuth.unblock_token_headers = c("authorization"))
– allows overriding the specified token exchange headers
(case-insensitive). Default blocked: Authorization,
Cookieoptions(shinyOAuth.async_timeout = 10000) – per-task
timeout in milliseconds for mirai async tasks. When using mirai with
dispatcher (the default), timed-out tasks are automatically cancelled
and resolve as a mirai error. Default is NULL (no timeout).
Ignored when falling back to the ‘future’ backendoptions(shinyOAuth.replay_async_conditions = FALSE) –
when FALSE, warnings and messages captured from async
workers are silently discarded instead of being re-emitted on the main R
process. Default is TRUE (replay all captured conditions).
Useful if worker diagnostics are too noisy or handled separately via
audit_hookoptions(shinyOAuth.default_expires_in = 3600) –
fallback token lifetime (in seconds) when the provider omits
expires_in from the token responseoptions(shinyOAuth.timeout = 5) – default HTTP timeout
(seconds) applied to all outbound requests (discovery, JWKS, token
exchange, userinfo). Increase if your provider/network is slowoptions(shinyOAuth.retry_max_tries = 3L) – maximum
attempts for transient failures (network errors, 408, 429, 5xx)options(shinyOAuth.retry_backoff_base = 0.5) – base
backoff in seconds used for exponential backoff with jitteroptions(shinyOAuth.retry_backoff_cap = 5) – per‑attempt
cap on backoff seconds (before jitter)options(shinyOAuth.retry_status = c(408L, 429L, 500:599))
– HTTP statuses considered transient and retriedoptions(shinyOAuth.user_agent = "shinyOAuth/<version> R/<version> httr2/<version>")
– override the default User‑Agent header applied to all outbound
requests. By default this string is built dynamically from the installed
package/runtime versions; set a custom string here if your organization
requires a specific formatoptions(shinyOAuth.allow_redirect = FALSE) – when
FALSE (default), all sensitive HTTP requests (token
exchange, refresh, introspection, revocation, userinfo, OIDC discovery,
JWKS) refuse to follow redirects and reject 3xx responses. This prevents
authorization codes, tokens, and PKCE verifiers from leaking to redirect
targets. Set to TRUE only when you deliberately accept that
redirect-following risk for a specific deployment; this opt-in is
honored in all sessionsoptions(shinyOAuth.max_body_bytes = 1048576) – maximum
response body size (bytes, default 1 MiB) accepted from OAuth endpoints
(token, introspection, userinfo, discovery, JWKS). Curl aborts the
transfer early when Content-Length exceeds this limit; a
post-download guard catches chunked responses. Increase if a provider
legitimately returns larger payloadsoptions(shinyOAuth.allow_non_atomic_state_store = TRUE)
– allow non-atomic $get() + $remove() fallback
for shared state stores (e.g., cachem::cache_disk()) that
do not implement $take(). By default, ‘shinyOAuth’ errors
when a non-cachem::cache_mem() store lacks
$take(), because the non-atomic fallback cannot guarantee
single-use state consumption under concurrent access (TOCTOU replay
window). Setting this option to TRUE downgrades the error
to a one-time warning and allows the fallback to proceed. Not
recommended for production without additional replay protection.options(shinyOAuth.state_max_token_chars = 8192) –
maximum allowed length of the base64url-encoded state query
parameteroptions(shinyOAuth.state_max_wrapper_bytes = 8192) –
maximum decoded byte size of the outer JSON wrapper (before
parsing)options(shinyOAuth.state_max_ct_b64_chars = 8192) –
maximum allowed length of the base64url-encoded ciphertext inside the
wrapperoptions(shinyOAuth.state_max_ct_bytes = 8192) – maximum
decoded byte size of the ciphertext before attempting AES-GCM
decryptThese prevent maliciously large state parameters from causing excessive CPU or memory usage during decoding and decryption.
options(shinyOAuth.callback_max_code_bytes = 4096) –
maximum byte length of the code query parameteroptions(shinyOAuth.callback_max_state_bytes = 8192) –
maximum byte length of the state query parameter (outer
token string)options(shinyOAuth.callback_max_error_bytes = 256) –
maximum byte length of the error query parameteroptions(shinyOAuth.callback_max_error_description_bytes = 4096)
– maximum byte length of the error_description query
parameteroptions(shinyOAuth.callback_max_error_uri_bytes = 2048)
– maximum byte length of the error_uri query parameteroptions(shinyOAuth.callback_max_iss_bytes = 2048) –
maximum byte length of the iss query parameter (RFC 9207
issuer identification)options(shinyOAuth.callback_max_query_bytes = <derived>)
– maximum total byte length of the raw callback query string (pre-parse
guard)options(shinyOAuth.callback_max_browser_token_bytes = 256)
– maximum byte length of the browser_token argument
accepted by handle_callback()options(shinyOAuth.callback_max_form_post_body_bytes = <derived>)
– maximum byte length of the raw form_post callback body
before parsingoptions(shinyOAuth.callback_max_form_post_handle_bytes = 128)
– maximum byte length of the transient shinyOAuth_form_post
handle query parameteroptions(shinyOAuth.callback_max_form_post_id_bytes = 256)
– maximum byte length of the transient
shinyOAuth_form_post_id module-id query parameterThese apply before any hashing/auditing/state parsing, and exist to
prevent memory/log amplification from extremely large callback URLs or
form_post bodies.
options(shinyOAuth.skip_browser_token = TRUE) – skip
browser cookie binding in tests or interactive sessionsoptions(shinyOAuth.skip_id_sig = TRUE) – skip ID token
signature verification in tests or interactive sessionsoptions(shinyOAuth.allow_unsigned_userinfo_jwt = TRUE)
– accept unsigned (alg=none) UserInfo JWTs in tests or
interactive sessions; outside those contexts ‘shinyOAuth’ errors instead
of honoring itoptions(shinyOAuth.debug = TRUE) – re‑raise errors
during token exchangeoptions(shinyOAuth.expose_error_body = TRUE) – include
sanitized HTTP bodies (may reveal details)Don’t enable these options in production. They disable key security checks or alter error behavior, and are intended for local testing/debugging only.
Below is a checklist of things you may want to think about when bringing your app to production:
OAuthClient and OAuthProvider, set
as many of the security options as your provider supportsOAuthClient request the minimum scopes
necessary; give your app registration only the permissions it needs$error_description to your users; never
expose tokens in UI or logsOAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET)htmltools::htmlEscape())vignette("audit-logging", package = "shinyOAuth")) and
monitor these logsWhile this R package has been developed with care and the OAuth 2.0/OIDC protocols contain many security features, no guarantees can be made in the realm of cybersecurity. For highly sensitive applications, consider a layered (‘defense-in-depth’) approach to security (for example, adding an IP whitelist as an additional safeguard).