Usage

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:

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

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

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.

‘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()
})

Using 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.

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

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

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.

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:

Async timeout (mirai)

Async condition replay

Token lifetime fallback

HTTP settings (timeout, retries, user agent)

State store

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()
  • options(shinyOAuth.callback_max_form_post_body_bytes = <derived>) – maximum byte length of the raw form_post callback body before parsing
  • options(shinyOAuth.callback_max_form_post_handle_bytes = 128) – maximum byte length of the transient shinyOAuth_form_post handle query parameter
  • options(shinyOAuth.callback_max_form_post_id_bytes = 256) – maximum byte length of the transient shinyOAuth_form_post_id module-id query parameter

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

Development/debugging

Don’t enable these options in production. They disable key security checks or alter error behavior, and are intended for local testing/debugging only.

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:

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:

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