Skip to contents

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.

For ordinary apps, use oauth_ui(ui, id = "auth", client = client) with the same module ID and client as the server. Use oauth_form_post_ui() for POST callbacks, or oauth_connections_ui() with a connection manager. These wrappers include the browser dependency; you do not need a separate use_shinyOAuth() call. That lower-level helper is for custom integrations providing equivalent callback handling and response headers.

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.

Deploying to Posit Connect or Connect Cloud? Test login at the direct app URL in a new browser tab or window. The dashboard’s embedded preview can prevent OAuth redirects. See Posit deployment instructions.

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.

  • auth[["authenticated"]] tells you whether login passed the configured checks.
  • auth[["token"]]@userinfo contains the user’s profile, when fetched. Fields depend on the provider: GitHub uses login for the username.
  • auth[["error"]] and auth[["error_description"]] describe a failed login. Show a simple message to users and use audit logging to investigate.

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.

Posit Connect and Connect Cloud

Test OAuth login in a new browser tab or window, outside the dashboard’s embedded app preview.

  1. On Connect Cloud, copy the app’s sharing URL from Settings > URL, such as https://<content-id>.share.connect.posit.cloud, or your configured custom URL. See the URL settings guide. On Posit Connect, use the app’s direct content URL.
  2. Paste that URL into a new browser tab or window and start login there. The administrative page at connect.posit.cloud/.../content/... is not the direct app URL or an OAuth callback URL.
  3. Set redirect_uri to the exact public callback URL registered with your provider. In the minimal example this is the app URL. With a dedicated route such as /callback, retain that callback path in the registration, but open the app’s entry URL to start login.

oauth_module_server() prints a console/log warning once per R process when POSIT_PRODUCT is CONNECT or CONNECT_CLOUD, or the legacy RSTUDIO_PRODUCT is CONNECT. This is a hosting reminder, not detection of an embedded browser; it also appears when you open the app correctly. If these markers are absent, the module retains its general browser reminder.

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:

  • A shared state_store with an atomic [["take"]]() operation: reading and deleting a pending login must happen as one indivisible operation.
  • The same secret state_key, so each process can read the encrypted login details. Supply at least 32 random bytes, stored in your deployment’s secret manager.
  • Matching provider and client settings.

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

  • Use HTTPS in production and keep credentials on the server.
  • Request only the scopes your app needs.
  • Check login and app-specific access rules in server code before using private data.
  • Keep tokens, complete login URLs, and detailed provider errors out of the UI and logs.
  • Render untrusted text through Shiny’s ordinary text/tag functions. Do not pass it to HTML() as markup; keep table escaping enabled.
  • Wrap the UI with oauth_ui() (or oauth_form_post_ui() when required). These send a browser privacy header that keeps callback URLs out of referrers.
  • If repeated login requests affect responsiveness, your hosting provider can help with optional rate limiting.
  • Keep the provider’s validation defaults and choose any extra protections to match your provider and deployment requirements.

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:

  • Rejected redirect URI: check that the registered callback URL and redirect_uri match, including scheme, host, port, and path.
  • Callback validation failure: use the registered app address in a regular browser and create the client outside server(). For multiple R processes, check the shared state store and key.
  • Rejected client credentials: check the environment variables after restarting R and match token_auth_style to the app registration.
  • Missing profile fields: check requested scopes and the provider’s profile format. Some providers return identity information only in ID token claims.
  • Rejected API request: check permissions, token expiry, and whether the API accepts tokens from the configured provider.

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.

  • Advanced security: form POST callbacks, certificates (mTLS), signed requests (JAR) and responses (JARM), pushed requests (PAR), and tokens tied to a key (DPoP).
  • Authentication flow: what the module checks and why.
  • Package options: logging, network limits, and debugging settings.
  • OpenTelemetry: exporting logs and timing information.