Usage

Overview

shinyOAuth provides a Shiny module for OAuth 2.0 authorization and OpenID Connect (OIDC) authentication. oauth_module_server() manages redirects, callback validation, token exchange, and session state. oauth_ui() supplies the browser setup required by the module.

This vignette covers provider and client configuration, manual login buttons, authenticated API calls, token refresh, and deployment. The examples use a GitHub OAuth App. Install shinyOAuth with install.packages("shinyOAuth"). For the protocol flow and validation rules, see Authentication flow.

GitHub app registration

  1. Register an OAuth App in GitHub’s developer settings. Set both the homepage URL and authorization callback URL to http://127.0.0.1:8100 for this local example.

  2. Store the app’s client ID and client secret in your R environment. You can open your user .Renviron with file.edit(path.expand("~/.Renviron")) and add:

    GITHUB_OAUTH_CLIENT_ID=your-client-id
    GITHUB_OAUTH_CLIENT_SECRET=your-client-secret

    Restart R after saving. Keep the secret out of app source files and Git.

Minimal Shiny module example

Save the following code as app.R and run it. Open http://127.0.0.1:8100 in a regular browser. Use the registered address; switching between localhost and 127.0.0.1 can interrupt login.

library(shiny)
library(shinyOAuth)

# Configure these once, outside server().
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 <- oauth_ui(fluidPage(
  h2("My app"),
  textOutput("greeting")
), id = "auth", client = client)

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

  output[["greeting"]] <- renderText({
    req(auth[["authenticated"]])
    paste("Hello,", auth[["token"]]@userinfo[["login"]])
  })
}

runApp(shinyApp(ui, server), port = 8100, launch.browser = FALSE)

The browser opens GitHub’s login or permission page, then returns to your app and displays your GitHub username. Use a regular browser: IDE viewers may prevent the redirects needed for login.

Provider, client, and token objects

shinyOAuth represents the flow with three S7 classes. An OAuthProvider holds the service’s endpoint URLs and protocol settings; a provider helper such as oauth_provider_github() creates it. An OAuthClient, created with oauth_client(), holds your app’s credentials, redirect URI, and requested scopes. After authentication, the module returns an OAuthToken as auth[["token"]], containing tokens and available user information.

The redirect URI, also called the callback URL, is the address where the provider sends the browser back. Register it with the provider and use the same value in oauth_client(), including scheme, host, port, path, and fixed query parameters. Fixed query names must not use OAuth/OIDC response fields such as state, code, error, iss, response, scope, token fields, or the shinyOAuth_form_post and shinyOAuth_form_post_id bridge fields, or the shinyOAuth_request_object retrieval field. These names are reserved for callback processing and are rejected when configuring the client. Application parameters such as tenant=one&tag=a&tag=b are supported. Scopes are named permissions, such as read:user; the provider defines which names are available.

Create your provider and client outside server() so they remain available when the browser returns from login. Create the module inside server() so each user has their own login state.

Authentication state and user information

auth is a Shiny reactiveValues object. Read it inside render*(), reactive(), or observe*(), just as you would read other reactive values.

The token is an S7 object: access its properties with @, as in auth[["token"]]@userinfo. See OAuthToken for the available properties.

Use req(auth[["authenticated"]]) before server code reads private data or performs an action that requires login. Hiding a UI element alone does not protect the server code behind it. Your app must also check any access rules, such as which accounts or groups may view a report. A successful login by itself does not grant access to everything in your app.

Manual login and logout buttons

The default is to start login automatically. To let users choose when to sign in, replace the example’s UI and server with:

ui <- oauth_ui(fluidPage(
  actionButton("login", "Sign in"),
  actionButton("logout", "Sign out"),
  textOutput("status")
), id = "auth", client = client)

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

  observeEvent(input[["login"]], auth[["request_login"]]())
  observeEvent(input[["logout"]], auth[["logout"]]())

  output[["status"]] <- renderText({
    if (isTRUE(auth[["authenticated"]])) {
      paste("Signed in as", auth[["token"]]@userinfo[["login"]])
    } else {
      "You are signed out. Use Sign in to continue."
    }
  })
}

auth[["logout"]]() clears the app’s local login and attempts to revoke its tokens if the provider supports revocation. It does not sign the user out of their GitHub, Google, or other provider account. The provider may therefore remember them on their next visit.

Authenticated API requests

Use perform_resource_req() to send an API request with the authenticated user’s access token. For example, add tableOutput("repositories") to the UI and this output to server():

output[["repositories"]] <- renderTable({
  req(auth[["authenticated"]])

  repos <- tryCatch({
    response <- perform_resource_req(
      auth[["token"]],
      "https://api.github.com/user/repos",
      query = list(per_page = 10)
    )
    httr2::resp_check_status(response)
    httr2::resp_body_json(response, simplifyVector = TRUE)
  }, error = function(e) NULL)

  validate(need(!is.null(repos), "Could not load repositories. Try again later."))
  validate(need(length(repos) > 0, "No repositories to show."))
  repos[, c("name", "private"), drop = FALSE]
})

Only send tokens to an API you intend to authorize. The example requests one page of results; fetching more pages depends on the API. Use resource_req() to build an httr2 request without sending it, or pass a prepared httr2 request to perform_resource_req(). See the Spotify example for another complete app.

Additional token response fields

Some providers return extra parameters alongside the access and ID tokens. Read these through auth[["token"]]@extra_fields, using the field names documented by your provider.

extra_fields contains only the additional parameters from the latest successful token response. A successful refresh replaces the entire list, even if the provider returns no extra parameters. The separate initial_extra_fields list preserves the initial successful code-exchange response’s extra parameters across refreshes. A new login starts a new snapshot; clearing the session removes both lists with the token.

For a provider that returns a parameter named custom_field:

# Inside reactive server code, after a successful login:
token <- auth[["token"]]
token@extra_fields[["custom_field"]]
token@initial_extra_fields[["custom_field"]]

# Distinguish an absent field from one explicitly returned as null.
"custom_field" %in% names(token@extra_fields)

The package preserves additional parameters without merging or interpreting them, or fetching resources automatically. Nested JSON objects, arrays, and explicit null entries remain available; form-encoded values remain strings. Use your provider’s documentation to decide how to handle omitted or changed fields after refresh. The initial snapshot is historical response data, not proof of current access permissions.

These parameters are separate from ID token claims. The id_token_validated flag applies to the ID token, not these response fields. Keep the complete lists out of logs and UI output because they can contain sensitive data. Normal print() and format() output redacts both lists.

Provider configuration and OIDC discovery

Use a built-in helper when available, such as oauth_provider_google(), oauth_provider_microsoft(), or oauth_provider_keycloak(). Each helper’s help page describes its setup. For an OpenID Connect service with a discovery URL, you can let shinyOAuth look up the service’s settings:

provider <- oauth_provider_oidc_discover(
  issuer = "https://login.example.com"
)
client <- oauth_client(
  provider = provider,
  client_id = Sys.getenv("OAUTH_CLIENT_ID"),
  client_secret = Sys.getenv("OAUTH_CLIENT_SECRET"),
  redirect_uri = "https://my-app.example.com",
  scopes = c("openid", "profile", "email")
)

Replace the example URLs and credentials with your own registration. An issuer is the provider’s identifier URL; copy it from the provider’s configuration. Discovery makes a network request, so run it during app setup. If your registration specifies a client authentication method, supply the matching token_auth_style to the provider helper; discovery describes the service’s capabilities, not the settings of your individual registration.

OpenID Connect (OIDC) is a login protocol: it supplies a signed ID token that shinyOAuth checks to identify the user. OAuth 2.0 grants permission to call APIs using an access token. GitHub and Spotify use OAuth without OIDC; their helpers fetch profile information through their own APIs. With OIDC, read validated identity details from auth[["token"]]@id_token_claims and check auth[["token"]]@id_token_validated. An access token alone is not proof of identity. The authentication guide explains more.

For a local Keycloak server using HTTP, opt in before creating the provider:

options(shinyOAuth.allow_insecure_oidc_loopback = TRUE)
provider <- oauth_provider_keycloak(
  base_url = "http://localhost:8080", realm = "shinyoauth"
)

This option is for local development only. Production provider URLs need HTTPS. The ordinary HTTP host option allows local app addresses; it does not relax OIDC discovery.

Assessing OAuth 2.1 configuration

shinyOAuth supports configurable OAuth 2.0 behavior and an optional assessment of the authorization-code/refresh client role against OAuth 2.1 draft 16, published 3 September 2026. This is an Internet-Draft, not a published RFC. check_oauth21() currently implements only that revision, with ruleset 1.1.0. It makes no network requests, creates no login state and never enables automatic enforcement. Existing provider examples and applications remain usable.

This example constructs an OAuth-only public client with S256 PKCE and HTTPS:

options(shinyOAuth.tls_min_version = "1.2") # Set before discovery or login.
provider <- oauth_provider(
  name = "Example authorization server",
  auth_url = "https://auth.example/authorize",
  token_url = "https://auth.example/token",
  token_auth_style = "public",
  use_pkce = TRUE,
  pkce_method = "S256"
)
client <- oauth_client(
  provider,
  client_id = "registered-client",
  redirect_uri = "https://app.example/callback",
  scopes = "read"
)
assessment <- check_oauth21(client)
assessment[["configuration_compliant"]]
assessment[["checks"]]

Choose credentials and endpoints to match your actual registration. Basic and body-secret authentication remain available; JWT authentication also supports the issuer-audience configuration described in advanced security. The checker does not require optional DPoP, mTLS, PAR, JAR and JARM together, nor does it require OIDC metadata for an OAuth-only provider.

The result records the draft, ruleset, package version, assessment time and operation scope. Its verdict has three meanings:

configuration_compliant Meaning
FALSE At least one applicable mandatory configuration check failed. This takes precedence over unknown findings.
NA No known mandatory failure, but at least one mandatory configuration prerequisite is unresolved. Provider-only assessments are partial.
TRUE Applicable mandatory checks in the recorded configuration scope passed. External behavior remains unverified.

Every finding has a stable ID, status, requirement strength, evidence source, remediation, reference and affects_verdict flag. Unmet recommendations do not turn the verdict into FALSE. Code and refresh, enabled PAR, required UserInfo and enabled introspection are included. Include optional operations explicitly:

assessment <- check_oauth21(
  client,
  context = list(operations = c("introspection", "revocation"))
)

The checker does not contact a server to establish PKCE enforcement, refresh rotation/replay protection, secret custody, registered redirect matching or browser/proxy TLS. It cannot assess future resource URLs or later arbitrary request mutations. Actual application scope enforcement and estimated token expiry also require review. A positive report is a bounded configuration result, not certification. Rerun it after changing options, objects or the runtime.

Applications choose how to use the result. For example, a deployment script may decide to block a known failure and separately flag unresolved prerequisites:

if (identical(assessment[["configuration_compliant"]], FALSE)) {
  stop("Resolve the mandatory configuration findings before deployment.")
}
if (is.na(assessment[["configuration_compliant"]])) {
  message("Review unresolved configuration prerequisites before deployment.")
}

S256 is the straightforward PKCE choice. Legacy plain remains constructible but fails this draft assessment. Omitting PKCE has a narrow exception under draft section 7.5.1.1: confidential client authentication, correct OIDC nonce validation, and server assurance for the particular deployment and request. Missing local prerequisites fail; unestablished server assurance is unresolved. context = list(nonce_exception = TRUE) records an explicit declaration of that assurance after it has been established. It cannot supply missing local settings and is never treated as observed server evidence. S256 remains recommended.

Multiple authorization servers

Create one client and module for each registration. Give each authorization server a distinct registered callback path and list the same clients in the UI wrapper. Module IDs alone do not distinguish browser callback routes.

redirects <- c("https://app.example/oauth/a", "https://app.example/oauth/b")
# provider_a and provider_b are independently configured OAuth/OIDC providers.
clients <- list(
  auth_a = oauth_client(
    provider_a, client_id = Sys.getenv("SITE_A_CLIENT_ID"),
    client_secret = Sys.getenv("SITE_A_CLIENT_SECRET"),
    redirect_uri = redirects[[1]], scopes = c("read"),
    authorization_server_mode = "multi_redirect_uri",
    authorization_server_redirect_uris = redirects
  ),
  auth_b = oauth_client(
    provider_b, client_id = Sys.getenv("SITE_B_CLIENT_ID"),
    client_secret = Sys.getenv("SITE_B_CLIENT_SECRET"),
    redirect_uri = redirects[[2]], scopes = c("read"),
    authorization_server_mode = "multi_redirect_uri",
    authorization_server_redirect_uris = redirects
  )
)
ui <- oauth_ui(fluidPage(
  actionButton("connect_a", "Connect A"),
  actionButton("connect_b", "Connect B")
), clients = clients)
server <- function(input, output, session) {
  a <- oauth_module_server("auth_a", clients[["auth_a"]], auto_redirect = FALSE)
  b <- oauth_module_server("auth_b", clients[["auth_b"]], auto_redirect = FALSE)
  observeEvent(input[["connect_a"]], a[["request_login"]]())
  observeEvent(input[["connect_b"]], b[["request_login"]]())
}
shinyApp(ui, server, uiPattern = ".*")

Register those exact redirect URIs with the respective services and configure the web host to serve the app at both paths. Shared routes require independent authorization-server identification (multi_issuer, using RFC 9207 or JARM); the static registry rejects multiple clients with the same issuer on one route. See OAuth Security BCP section 4.4.2.

This pattern routes multiple modules; it does not retain their credentials across navigation. Following the authorization redirect for B can end the Shiny session holding A. A new session starts with empty module credentials even if the clients share a pending-state store. To preserve authorizations across navigation, use the separate connection manager with browser or account retention. See Keep and use multiple OAuth authorizations for a complete two-service example.

For healthcare APIs, Use SMART on FHIR from Shiny shows discovery, client registration settings, patient requests and EHR launch using the same manager.

Additional token fields are already available server-side. For example, a[["token"]]@initial_extra_fields[["patient"]] reads an initial SMART patient value, while a[["token"]]@extra_fields contains only the latest successful response’s extras. Refresh may omit patient. Neither list is validated identity data or evidence of permission to read a resource. Ordinary clients do not infer SMART discovery, launch handling, scope equivalence, or a FHIR destination from these fields. Keep the matching client, token and approved resource together when making requests, and keep raw context out of logs and session summaries.

Requests bound to a client and resource

These optional helpers group the client, API address and current credentials used for a request. They are package API choices; OAuth and SMART on FHIR do not require these classes.

Object What it represents Example
OAuthClient Registration settings and optional approved API addresses Hospital A’s client ID, scopes and https://a.example/fhir
OAuthConnection Access to one Shiny session’s current credentials through that configuration This session’s authorized requests to Hospital A

The client holds shared configuration, with no user’s token. A connection reads the current token for each request. The object refers to credentials that can change instead of keeping a token copy. The existing reactive token already updates on refresh; the connection adds client selection, API-address restrictions and session checks. It does not implement refresh or retention by itself.

Set resource_bases on oauth_client() outside server(), then create a connection for that client’s module inside server():

client <- oauth_client(
  provider, client_id = "registered-app",
  redirect_uri = "https://app.example/callback", scopes = "read",
  resource_bases = c(records = "https://api.example/v1"),
  required_scopes = "read" # A subset of the requested scopes.
)
server <- function(input, output, session) {
  auth <- oauth_module_server("auth", client)
  connection <- oauth_connection(client, reactive(auth[["token"]]))
  records <- reactive({
    req(connection[["is_usable"]]())
    connection[["request"]]("records", "records", query = list(limit = 20)) |>
      httr2::resp_body_json()
  })
}

The reference reads the latest token after refresh and becomes unusable after logout, token expiry, or session end. A reference from another Shiny session cannot read its summary or make requests. This adapter still uses the legacy module’s lifecycle and does not retain credentials across navigation.

resource_bases restricts the token’s destinations; it does not request an audience or prove an opaque token’s audience. Wire the token expression to the module using client. Resource indicators remain an explicit client setting (RFC 8707).

Each request selects a resource ID and resolves a path against its full base. An absolute pagination or resource reference must stay within that base too. For example, /v1-other is outside /v1, even on the same hostname. Ambiguous path syntax is rejected before URL normalization, and redirects are disabled. General URL options cannot relax this policy. These comparisons use URI origin and path rules from RFC 3986.

Optional permissions can produce a limited connection. Declare an operation’s extra permissions with [["request"]](..., required_scopes = "write"); the current grant must cover them. No generic mapping from API paths to OAuth scopes is assumed. [["summary"]]() contains the opaque connection ID, client label, local status, expiry, and resource IDs. It excludes credentials and raw context.

Asynchronous execution

By default, network work runs in the app’s R process. A slow provider can make other sessions on that process wait too. To run the module’s network work in background R processes, install mirai and promises, then configure workers before server():

mirai::daemons(2)
shiny::onStop(function() mirai::daemons(0))

server <- function(input, output, session) {
  auth <- oauth_module_server("auth", client, async = TRUE)
  # Add your outputs and observers here.
}

Alternatively, configure future::plan(future::multisession, workers = 2) and use async = TRUE. If both backends are configured, mirai takes priority. future::sequential() runs in the same R process and does not avoid blocking.

For an app using future instead of mirai, configure its worker plan before starting the server and release the workers when the app stops:

future::plan(future::multisession, workers = 2)
shiny::onStop(function() future::plan(future::sequential))

server <- function(input, output, session) {
  auth <- oauth_module_server("auth", client, async = TRUE)
  # Add your outputs and observers here.
}

This setting covers the module’s operations; API calls you write in your own outputs still run where you call them. Discovery during app setup also stays synchronous. See oauth_module_server() for advanced exceptions and the options reference for timeouts and retries.

Token expiry and session length

Access tokens usually expire. If the provider supplies a refresh token, the module can obtain a replacement before expiry with refresh_proactively = TRUE. Otherwise, users need to sign in again when their token expires.

Use reauth_after_seconds to set a maximum time since interactive login; refreshing a token does not restart that timer. For OIDC, the module also requests a fresh provider login and checks its time. OAuth-only providers can only be given an ordinary authorization request.

Keep indefinite_session = FALSE unless you deliberately want the local session to continue with an expired token or after refresh fails. Setting it to TRUE also disables the reauth_after_seconds limit; it does not extend the token’s validity at the provider.

Deployment

Replace the local callback URL with the app’s public HTTPS URL, and register that same URL with the provider. Open the app directly in a browser tab. An app embedded in another page may not be able to complete login. On Posit Connect Cloud, use the app’s direct URL as described in its URL settings guide.

Multiple R processes

The default configuration stores pending logins in one R process. If a login can start on one process and return to another, those processes need:

Use custom_cache() to connect a shared database or Redis store. Plain cachem::cache_disk() is unsuitable for shared login state because separate reads and deletes can let two requests use the same entry. See custom_cache() for the backend contract.

Hosted Request Objects (request_object_mode = "request_uri") also use this store, in separate records. Their JWT claims are readable when signed without JWE encryption; the pending-login record’s state_key sealing does not cover them. Apply the store’s access controls and expiry to both record types.

Security checklist

The module links a returning login to the browser using an origin-scoped token in tab-scoped session storage and an independent, short-lived cookie marker. Each transaction has its own marker. Cookies and session storage must both be available; complete login in the tab that started it. JavaScript reads this binding, so preventing injected scripts (cross-site scripting, or XSS) in your app matters. This link cannot establish which account you expected to sign in: check that account yourself when your app has such a requirement.

Treat the entire hostname as a trust boundary and use a dedicated hostname if other services are untrusted. Cookies are shared across ports, even with __Host-, Secure, or HttpOnly. The session-storage check prevents another port from adopting a cookie as a binding, but cannot prevent cookie disruption. Application callback routes distinguish records within a tab. Same-origin scripts can still access session storage.

Troubleshooting

Use audit logging to identify the failing operation. Common configuration issues include:

Keep token and callback validation enabled while diagnosing configuration errors.

Further configuration

The oauth_module_server() examples include complete apps for automatic login, a manual login button, and fetching GitHub repositories with the user’s access token. The oauth_provider() examples cover manual OAuth/OIDC setup, discovery, and named providers; the Microsoft example also shows authentication-state summaries and error handling.