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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
state_store with an atomic
[["take"]]() operation: reading and deleting a pending
login must happen as one indivisible operation.state_key, so each process can read the
encrypted login details. Supply at least 32 random bytes, stored in your
deployment’s secret manager.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.
HTML() as markup; keep table escaping
enabled.oauth_ui() (or
oauth_form_post_ui() when required). These send a browser
privacy header that keeps callback URLs out of referrers.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.
Use audit logging to identify the failing operation. Common configuration issues include:
redirect_uri match, including scheme,
host, port, and path.server(). For multiple R processes, check the shared state
store and key.token_auth_style to
the app registration.Keep token and callback validation enabled while diagnosing configuration errors.
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.