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
Register an OAuth App in GitHub’s developer settings. Set both the homepage URL and authorization callback URL to
http://127.0.0.1:8100for this local example.-
Store the app’s client ID and client secret in your R environment. You can open your user
.Renvironwithfile.edit(path.expand("~/.Renviron"))and add:GITHUB_OAUTH_CLIENT_ID=your-client-id GITHUB_OAUTH_CLIENT_SECRET=your-client-secretRestart 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"]]@userinfocontains the user’s profile, when fetched. Fields depend on the provider: GitHub usesloginfor the username. -
auth[["error"]]andauth[["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.
- 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. - 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. - Set
redirect_urito 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_storewith 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()(oroauth_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_urimatch, 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_styleto 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.