Skip to contents

Overview

‘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:

  • Building the login URL and redirecting users when needed
  • Creating and checking state, nonce, and PKCE values
  • Exchanging the authorization code for tokens and validating the result
  • Optionally loading user info and validating ID token signatures/claims
  • Optionally refreshing tokens before expiry or triggering re-login
  • When DPoP is configured, attaching DPoP proofs to token and protected-resource requests and handling RFC 9449 nonce challenges automatically

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 shinyOAuth’s OpenTelemetry support, see: vignette("opentelemetry", package = "shinyOAuth").

Minimal Shiny module example

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.

Manual login button variant

This version does the same thing, but waits for the user to click a button before starting login.

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(),
  actionButton("login_btn", "Login"),
  uiOutput("login")
)

server <- function(input, output, session) {
  auth <- oauth_module_server(
    "auth",
    client,
    auto_redirect = FALSE
  )

  observeEvent(input$login_btn, {
    auth$request_login()
  })

  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)

Making authenticated API calls

After login succeeds, you can use the access token to call an API on the user’s behalf. client_bearer_req() builds an authorized httr2 request with the correct authorization scheme for the current token type.

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)
    req <- client_bearer_req(auth$token, "https://api.github.com/user/repos")
    resp <- httr2::req_perform(req)

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

Async mode to keep UI responsive

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
  )
  
  # ...
}

‘future’ async backend

# Set up workers at the top of your app
future::plan(future::multisession, workers = 2)

server <- function(input, output, session) {
  auth <- oauth_module_server(
    "auth",
    client,
    auto_redirect = TRUE,
    async = TRUE # Run token exchange & refresh off the main thread
  )
  
  # ...
}

Logout

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

observeEvent(input$logout_btn, {
  auth$logout()
})

Global options

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.

Observability/logging

  • options(shinyOAuth.audit_hook = function(event){ ... }) – receive structured audit and error events
  • options(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)
  • options(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 configured
  • options(shinyOAuth.otel_tracing_enabled = FALSE) – disable shinyOAuth OpenTelemetry span creation and async trace-context propagation. Default: TRUE
  • options(shinyOAuth.otel_logging_enabled = FALSE) – disable shinyOAuth OpenTelemetry log emission. Default: TRUE

See vignette("audit-logging", package = "shinyOAuth") for details about audit hooks, and vignette("opentelemetry", package = "shinyOAuth") for more details about logs and traces via OpenTelemetry.

Networking/security

  • options(shinyOAuth.leeway = 30) – default clock skew leeway (seconds) for ID token exp/iat/nbf checks and state payload issued_at future check
  • options(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 check
  • options(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 whitelist
  • options(shinyOAuth.allow_hs = TRUE) – opt‑in HMAC validation for ID tokens (HS256/HS384/HS512). Requires a strictly server‑side client_secret
  • options(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 default
  • options(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 modes

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

Extra parameter overrides

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.

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, claims
  • request 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_type
  • options(shinyOAuth.unblock_token_headers = c("authorization")) – allows overriding the specified token exchange headers (case-insensitive). Default blocked: Authorization, Cookie

Async timeout (mirai)

  • options(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’ backend

Async condition replay

  • options(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_hook

Token lifetime fallback

  • options(shinyOAuth.default_expires_in = 3600) – fallback token lifetime (in seconds) when the provider omits expires_in from the token response

HTTP settings (timeout, retries, user agent)

  • options(shinyOAuth.timeout = 5) – default HTTP timeout (seconds) applied to all outbound requests (discovery, JWKS, token exchange, userinfo). Increase if your provider/network is slow
  • options(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 jitter
  • options(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 retried
  • options(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 format
  • options(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 sessions
  • options(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 payloads

State store

  • options(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.

Size caps

State envelope

  • options(shinyOAuth.state_max_token_chars = 8192) – maximum allowed length of the base64url-encoded state query parameter
  • options(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 wrapper
  • options(shinyOAuth.state_max_ct_bytes = 8192) – maximum decoded byte size of the ciphertext before attempting AES-GCM decrypt

These prevent maliciously large state parameters from causing excessive CPU or memory usage during decoding and decryption.

Callback query

  • options(shinyOAuth.callback_max_code_bytes = 4096) – maximum byte length of the code query parameter
  • options(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 parameter
  • options(shinyOAuth.callback_max_error_description_bytes = 4096) – maximum byte length of the error_description query parameter
  • options(shinyOAuth.callback_max_error_uri_bytes = 2048) – maximum byte length of the error_uri query parameter
  • options(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()

These apply before any hashing/auditing/state parsing, and exist to prevent memory/log amplification from extremely large callback URLs.

Development/debugging

  • options(shinyOAuth.skip_browser_token = TRUE) – skip browser cookie binding in tests or interactive sessions
  • options(shinyOAuth.skip_id_sig = TRUE) – skip ID token signature verification in tests or interactive sessions
  • options(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 it
  • options(shinyOAuth.debug = TRUE) – re‑raise errors during token exchange
  • options(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.

oauth_module_server() binds the browser and server session with a short‑lived cookie that must be readable from client‑side JavaScript to bridge values into Shiny.

The cookie ensures that the same browser which initiated login is the one receiving the callback. This specifically prevents an attack where an attacker tricks a user into clicking a link which initiates login for the attacker’s account, confusing the user into logging in as the attacker (login confusion).

The cookie is set with the HttpOnly flag disabled so that it can be read by JavaScript. This is necessary to bridge the cookie value into Shiny. However, this means that if your app has XSS vulnerabilities, an attacker could read the cookie too.

While this is a relatively limited attack vector, you should still take care to prevent XSS vulnerabilities in your app. An important mitigation is to sanitize user inputs before rendering them in the UI (e.g., using htmltools::htmlEscape()).

Multi‑process deployments: share state store, key, and policy

When you run multiple Shiny R processes (e.g., multiple workers, Shiny Server Pro, RStudio Connect, Docker/Kubernetes replicas, or any non‑sticky load balancer), you must ensure that:

  • All workers share the same state store with atomic single-use semantics. Use custom_cache() with an atomic $take() method backed by a shared store (e.g., Redis GETDEL, SQL DELETE ... RETURNING). Plain cachem::cache_disk() is not safe as a shared state store because its $get() + $remove() are not atomic and may allow replay attacks under concurrent access; the default cachem::cache_mem() is per‑process only and is not shared. See ?custom_cache for details on implementing $take();
  • All workers share the same state key (e.g., read from environment variable; by default, a random key is generated per client instance which is then not shared);
  • All workers use the same effective OAuthClient / OAuthProvider settings which are included in the fingerprint used for state binding.

This is because during the authorization code + PKCE flow, ‘shinyOAuth’ creates an encrypted “state envelope” which is stored in a cache (the state_store) and echoed back via the state query parameter. The envelope is sealed with AES‑GCM using your state_key. If the callback lands on a different worker than the one that initiated login, that worker must be able to both read the cached entry and decrypt the envelope using the same key. If workers have different keys, decryption will fail and the login flow will abort with a state error.

When providing a custom state key, please ensure it has high entropy (minimum 32 characters or 32 raw bytes; recommended 64–128 characters) to prevent offline guessing attacks against the encrypted state. Do not use short or human‑memorable passphrases.

Security checklist

Below is a checklist of things you may want to think about when bringing your app to production:

  • Use HTTPS everywhere in production
  • Verify issuer used in your provider is correct
  • In your OAuthClient and OAuthProvider, set as many of the security options as your provider supports
  • Have your OAuthClient request the minimum scopes necessary; give your app registration only the permissions it needs
  • Do not show $error_description to your users; never expose tokens in UI or logs
  • Keep secrets safe in environment variables (e.g., OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET)
  • Sanitize user inputs before rendering them in the UI (e.g., using htmltools::htmlEscape())
  • Make use of audit logging (see vignette("audit-logging", package = "shinyOAuth")) and monitor these logs
  • Use a provider which enforces strong authentication (e.g., multi-factor authentication)
  • Set Content Security Policy (CSP) headers to restrict resource loading and mitigate XSS attacks; (requires middleware; can’t be done in Shiny)
  • Log IP addresses of those accessing your app (requires middleware; can’t be done in Shiny)

While 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).