# Usage

## Overview

‘shinyOAuth’ helps a Shiny app send users to an OAuth 2.0 or OpenID Connect (OIDC) provider, handle the return callback, and keep the flow secure by default. It takes care of:

- Building the login URL and redirecting users when needed
- Creating and checking state, nonce, and PKCE values
- Exchanging the authorization code for tokens and validating the result
- Optionally loading user info and validating ID token signatures/claims
- Optionally refreshing tokens before expiry or triggering re-login

For a full step-by-step protocol breakdown, see the separate vignette: `vignette("authentication-flow", package = "shinyOAuth")`.

For a detailed explanation of audit logging key events during the flow, see: `vignette("audit-logging", package = "shinyOAuth")`.

For a dedicated description of OpenTelemetry support in ‘shinyOAuth’, see: `vignette("opentelemetry", package = "shinyOAuth")`.

## Minimal Shiny module example

Below is a minimal example using a GitHub OAuth app (the same setup shown in the README). Register an OAuth 2.0 application at https://github.com/settings/developers and set environment variables `GITHUB_OAUTH_CLIENT_ID` and `GITHUB_OAUTH_CLIENT_SECRET`.

    library(shiny)
    library(shinyOAuth)

    provider <- oauth_provider_github()

    client <- oauth_client(
      provider = provider,
      client_id = Sys.getenv("GITHUB_OAUTH_CLIENT_ID"),
      client_secret = Sys.getenv("GITHUB_OAUTH_CLIENT_SECRET"),
      redirect_uri = "http://127.0.0.1:8100",
      scopes = c("read:user", "user:email")
    )

    ui <- fluidPage(
      # Include JavaScript dependency:
      use_shinyOAuth(),
      # Render login status & user info:
      uiOutput("login")
    )

    server <- function(input, output, session) {
      auth <- oauth_module_server("auth", client, auto_redirect = TRUE)
      output$login <- renderUI({
        if (auth$authenticated) {
          user_info <- auth$token@userinfo
          tagList(
            tags$p("You are logged in!"),
            tags$pre(paste(capture.output(str(user_info)), collapse = "\n"))
          )
        } else {
          tags$p("You are not logged in.")
        }
      })
    }

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

    # Open the app in your regular browser at http://127.0.0.1:8100
    # (viewers in RStudio/Positron/etc. cannot perform necessary redirects)

`use_shinyOAuth()` must be included once in your UI. It loads the JavaScript helper that the login flow depends on. Place it near the top of your UI, for example inside `fluidPage()`, `tagList()`, or `bslib::page()`.

Open the app in a regular browser, not an IDE viewer. Embedded viewers in tools like RStudio or Positron usually cannot complete the required redirects.

## Manual login button variant

This version does the same thing, but waits for the user to click a button before starting login.

    library(shiny)
    library(shinyOAuth)

    provider <- oauth_provider_github()

    client <- oauth_client(
      provider = provider,
      client_id = Sys.getenv("GITHUB_OAUTH_CLIENT_ID"),
      client_secret = Sys.getenv("GITHUB_OAUTH_CLIENT_SECRET"),
      redirect_uri = "http://127.0.0.1:8100",
      scopes = c("read:user", "user:email")
    )

    ui <- fluidPage(
      use_shinyOAuth(),
      actionButton("login_btn", "Login"),
      uiOutput("login")
    )

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

      observeEvent(input$login_btn, {
        auth$request_login()
      })

      output$login <- renderUI({
        if (auth$authenticated) {
          user_info <- auth$token@userinfo
          tagList(
            tags$p("You are logged in!"),
            tags$pre(paste(capture.output(str(user_info)), collapse = "\n"))
          )
        } else {
          tags$p("You are not logged in.")
        }
      })
    }

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

    # Open the app in your regular browser at http://127.0.0.1:8100
    # (viewers in RStudio/Positron/etc. cannot perform necessary redirects)

## Making authenticated API calls

After login succeeds, you can use the access token to call an API on the user’s behalf. `perform_resource_req()` is the easiest option for most call sites: it builds an authorized `httr2` request, performs it, and when the token type is `DPoP` it also handles a one-time `DPoP-Nonce` challenge retry. Use `resource_req()` when you need to inspect or customize the `httr2` request before sending it yourself.

The example below calls the GitHub API to fetch the user’s repositories.

    library(shiny)
    library(shinyOAuth)

    provider <- oauth_provider_github()

    client <- oauth_client(
      provider = provider,
      client_id = Sys.getenv("GITHUB_OAUTH_CLIENT_ID"),
      client_secret = Sys.getenv("GITHUB_OAUTH_CLIENT_SECRET"),
      redirect_uri = "http://127.0.0.1:8100",
      scopes = c("read:user", "user:email")
    )

    ui <- fluidPage(
      use_shinyOAuth(),
      uiOutput("ui")
    )

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

      repositories <- reactiveVal(NULL)

      observe({
        req(auth$authenticated)

        # Example additional API request using the access token
        # (e.g., fetch user repositories from GitHub)
        resp <- perform_resource_req(
          auth$token,
          "https://api.github.com/user/repos"
        )

        if (httr2::resp_is_error(resp)) {
          repositories(NULL)
        } else {
          repos_data <- httr2::resp_body_json(resp, simplifyVector = TRUE)
          repositories(repos_data)
        }
      })

      # Render username + their repositories
      output$ui <- renderUI({
        if (isTRUE(auth$authenticated)) {
          user_info <- auth$token@userinfo
          repos <- repositories()

          return(tagList(
            tags$p(paste("You are logged in as:", user_info$login)),
            tags$h4("Your repositories:"),
            if (!is.null(repos)) {
              tags$ul(
                Map(function(url, name) {
                  tags$li(tags$a(href = url, target = "_blank", name))
                }, repos$html_url, repos$full_name)
              )
            } else {
              tags$p("Loading repositories...")
            }
          ))
        }

        return(tags$p("You are not logged in."))
      })
    }

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

    # Open the app in your regular browser at http://127.0.0.1:8100
    # (viewers in RStudio/Positron/etc. cannot perform necessary redirects)

For an example application which fetches data from the Spotify web API, see: `vignette("example-spotify", package = "shinyOAuth")`.

## Async mode to keep UI responsive

By default, `oauth_module_server()` performs network operations (authorization-code exchange, refresh, userinfo) on the main R thread. That keeps setup simple, but a slow provider or retry delay can temporarily block the Shiny worker handling the session.

To avoid blocking, enable async mode and configure an async backend. ‘shinyOAuth’ supports both `mirai` and `future` and auto-detects whichever one you have configured. If both are set up, `mirai` takes precedence.

For the `future` backend, use a non-sequential plan such as `future::multisession()` or `future::multicore()` where available. `future::sequential()` still runs in the same R process, so it does not move network work off the main R thread.

If you need to keep `async = FALSE`, you may consider reducing retry behaviour to limit blocking during provider incidents. See the global options section for timeout and retry settings.

### ‘mirai’ async backend (recommended)

    # Set up daemons at the top of your app (or in global.R)
    mirai::daemons(2)

    # Clean up daemons when the app stops
    onStop(function() mirai::daemons(0))

    server <- function(input, output, session) {
      auth <- oauth_module_server(
        "auth",
        client,
        auto_redirect = TRUE,
        async = TRUE # Run token exchange & refresh off the main thread
      )

      # ...
    }

### ‘future’ async backend

    # Set up workers at the top of your app
    future::plan(future::multisession, workers = 2)

    server <- function(input, output, session) {
      auth <- oauth_module_server(
        "auth",
        client,
        auto_redirect = TRUE,
        async = TRUE # Run token exchange & refresh off the main thread
      )

      # ...
    }

## Logout

To log out the user, call `auth$logout()`. This clears the local session, sets `auth$error` to `"logged_out"`, reissues a fresh browser token for the next login attempt, and attempts to revoke tokens at the provider (if a revocation endpoint is available):

    observeEvent(input$logout_btn, {
      auth$logout()
    })

## Using `response_mode = "form_post"`

The response mode determines how the provider returns the authorization response to the app after the user authenticates. The effective default is the normal query callback flow, which means the provider redirects back to the app with query parameters (e.g., `?code=...&state=...`) and shinyOAuth does not send a `response_mode` parameter unless you configure one.

For most Shiny apps, query is the preferred response mode because it works seamlessly with Shiny’s routing and does not require any special UI handling. It is the default and does not require setting `response_mode` explicitly.

For some apps, when your provider explicitly requires or recommends `response_mode = "form_post"`, you can configure that on the client. Because Shiny apps do not handle POST callbacks by default, you need to enable this by wrapping your UI with `oauth_form_post_ui()`. This allows the provider to POST the authorization response back to the app. That wrapper also injects the shinyOAuth browser dependency automatically, so you do not need a separate `use_shinyOAuth()` call in the wrapped UI. The `/callback` path below is only an example sub-route; using the app root is also fine as long as the provider redirect URI matches the path handled by `oauth_form_post_ui()`. Here’s how you can set it up:

This is the plain OAuth/OIDC Form Post Response Mode: the POST body contains parameters such as `code`, `state`, `error`, and `iss`. JWT Secured Authorization Response Mode (JARM) values such as `form_post.jwt` use a different JWT `response` payload and are not currently supported.

    library(shiny)
    library(shinyOAuth)

    provider <- oauth_provider_keycloak(
      base_url = "http://localhost:8080",
      realm = "shinyoauth"
    )

    client <- oauth_client(
      provider = provider,
      client_id = "shiny-public",
      client_secret = "",
      # `/callback` is only an example sub-route. The app root also works if the
      # provider redirect URI matches the path handled by `oauth_form_post_ui()`
      redirect_uri = "http://127.0.0.1:8100/callback",
      scopes = c("openid", "profile", "email"),
      response_mode = "form_post"
    )

    base_ui <- fluidPage(
      uiOutput("login")
    )

    ui <- oauth_form_post_ui(base_ui, id = "auth", client = client)

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

      output$login <- renderUI({
        if (auth$authenticated) {
          tagList(
            tags$p("You are logged in!"),
            tags$pre(paste(capture.output(str(auth$token@userinfo)), collapse = "\n"))
          )
        } else {
          tags$p("You are not logged in.")
        }
      })
    }

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

    # Open the app in your regular browser at http://127.0.0.1:8100
    # (viewers in RStudio/Positron/etc. cannot perform necessary redirects)

If your `redirect_uri` is the app root (like `http://127.0.0.1:8100`), `uiPattern = ".*"` is usually harmless. If your `redirect_uri` is a sub-route (like `http://127.0.0.1:8100/callback`), use `uiPattern = ".*"` so Shiny routes that POST request through `oauth_form_post_ui()` before the app returns to its normal GET flow.

## Global options

The package provides several global options to customize behavior. Most apps can stay with the defaults; this section is mainly for cases where you want to tune logging, networking, or a specific advanced behavior.

### Observability/logging

- `options(shinyOAuth.audit_hook = function(event){ ... })` – receive structured audit and error events
- `options(shinyOAuth.audit_include_http = FALSE)` – exclude HTTP request details from audit events (default: `TRUE`)
- `options(shinyOAuth.audit_redact_http = FALSE)` – disable automatic redaction of sensitive data in audit events (default: `TRUE`). Debug only: raw mode can expose cookies, authorization headers, codes, state values, and client IP addresses
- `options(shinyOAuth.audit_digest_key = ...)` – shared key for HMAC-SHA256 digests used in audit/OTel attributes. By default, ‘shinyOAuth’ generates a random per-process key when this is not configured
- `options(shinyOAuth.otel_tracing_enabled = FALSE)` – disable ‘shinyOAuth’ OpenTelemetry span creation and async trace-context propagation. Default: `TRUE`
- `options(shinyOAuth.otel_logging_enabled = FALSE)` – disable ‘shinyOAuth’ OpenTelemetry log emission. Default: `TRUE`

See `vignette("audit-logging", package = "shinyOAuth")` for details about audit hooks, and `vignette("opentelemetry", package = "shinyOAuth")` for more details about logs and traces via OpenTelemetry.

### Networking/security

- `options(shinyOAuth.leeway = 30)` – default clock skew leeway (seconds) for ID token `exp`/`iat`/`nbf` checks and state payload `issued_at` future check
- `options(shinyOAuth.max_id_token_lifetime = 86400)` – maximum allowed ID token lifetime in seconds (`exp - iat`). Tokens whose lifetime exceeds this cap are rejected (OIDC Core §3.1.3.7 rule 9). Default `86400` (24 hours). Set to `Inf` to disable the check
- `options(shinyOAuth.allowed_non_https_hosts = c("localhost", "127.0.0.1", "::1", "[::1]"))` - allows hosts to use `http://` scheme instead of `https://`
- `options(shinyOAuth.allowed_hosts = c())` – when non‑empty, restricts accepted hosts to this whitelist
- `options(shinyOAuth.allow_hs = TRUE)` – opt‑in HMAC validation for ID tokens (HS256/HS384/HS512). Requires a strictly server‑side `client_secret`
- `options(shinyOAuth.client_assertion_ttl = 120L)` – lifetime in seconds for JWT client assertions used with `client_secret_jwt` or `private_key_jwt` token endpoint authentication. Finite values below 60 seconds are coerced to 60 seconds, finite values above 300 seconds are clamped to 300 seconds, and `NA` or non-finite values fall back to the 120-second default
- `options(shinyOAuth.state_fail_delay_ms = c(10, 30))` – adds a small randomized delay (in milliseconds) before any state validation failure (e.g., malformed token, IV/tag/ciphertext issues, or GCM authentication failure). This helps reduce timing side‑channels between different failure modes

Note on `allowed_hosts`: patterns support globs (`*`, `?`). Using a catch‑all like `"*"` matches any host and effectively disables endpoint host restrictions (scheme rules still apply). Avoid this unless you truly intend to accept any host; prefer pinning to your domain(s), e.g., `c(".example.com")`.

### Extra parameter overrides

Most users can ignore this section. By default, ‘shinyOAuth’ blocks certain security-critical parameters from being passed via `extra_auth_params`, `extra_token_params`, and `extra_token_headers`. This helps prevent accidental misconfiguration that could break state binding, PKCE, or client authentication.

`response_mode` now has a dedicated client argument via `oauth_client(..., response_mode = ...)`. Prefer that first-class API over setting `extra_auth_params$response_mode` manually.

If you have a specific, advanced use case where you need to override one of these blocked parameters, you can unblock them using the following options:

- `options(shinyOAuth.unblock_auth_params = c("redirect_uri"))` – allows overriding the specified authorization URL parameters. Default blocked: `response_type`, `client_id`, `redirect_uri`, `state`, `request_uri`, `request`, `scope`, `code_challenge`, `code_challenge_method`, `nonce`, `claims`
- `request` and `request_uri` stay blocked by default because ‘shinyOAuth’ manages them internally for PAR and Request Object flows; leave them reserved unless you are intentionally taking responsibility for a fully custom advanced flow.
- `options(shinyOAuth.unblock_token_params = c(...))` – allows overriding the specified token exchange parameters. Default blocked: `grant_type`, `code`, `redirect_uri`, `code_verifier`, `client_id`, `client_secret`, `client_assertion`, `client_assertion_type`
- `options(shinyOAuth.unblock_token_headers = c("authorization"))` – allows overriding the specified token exchange headers (case-insensitive). Default blocked: `Authorization`, `Cookie`

### Async timeout (mirai)

- `options(shinyOAuth.async_timeout = 10000)` – per-task timeout in milliseconds for mirai async tasks. When using mirai with dispatcher (the default), timed-out tasks are automatically cancelled and resolve as a mirai error. Default is `NULL` (no timeout). Ignored when falling back to the ‘future’ backend

### Async condition replay

- `options(shinyOAuth.replay_async_conditions = FALSE)` – when `FALSE`, warnings and messages captured from async workers are silently discarded instead of being re-emitted on the main R process. Default is `TRUE` (replay all captured conditions). Useful if worker diagnostics are too noisy or handled separately via `audit_hook`

### Token lifetime fallback

- `options(shinyOAuth.default_expires_in = 3600)` – fallback token lifetime (in seconds) when the provider omits `expires_in` from the token response

### HTTP settings (timeout, retries, user agent)

- `options(shinyOAuth.timeout = 5)` – default HTTP timeout (seconds) applied to all outbound requests (discovery, JWKS, token exchange, userinfo). Increase if your provider/network is slow
- `options(shinyOAuth.retry_max_tries = 3L)` – maximum attempts for transient failures (network errors, 408, 429, 5xx)
- `options(shinyOAuth.retry_backoff_base = 0.5)` – base backoff in seconds used for exponential backoff with jitter
- `options(shinyOAuth.retry_backoff_cap = 5)` – per‑attempt cap on backoff seconds (before jitter)
- `options(shinyOAuth.retry_status = c(408L, 429L, 500:599))` – HTTP statuses considered transient and retried
- `options(shinyOAuth.user_agent = "shinyOAuth/<version> R/<version> httr2/<version>")` – override the default User‑Agent header applied to all outbound requests. By default this string is built dynamically from the installed package/runtime versions; set a custom string here if your organization requires a specific format
- `options(shinyOAuth.allow_redirect = FALSE)` – when `FALSE` (default), all sensitive HTTP requests (token exchange, refresh, introspection, revocation, userinfo, OIDC discovery, JWKS) refuse to follow redirects and reject 3xx responses. This prevents authorization codes, tokens, and PKCE verifiers from leaking to redirect targets. Set to `TRUE` only when you deliberately accept that redirect-following risk for a specific deployment; this opt-in is honored in all sessions
- `options(shinyOAuth.max_body_bytes = 1048576)` – maximum response body size (bytes, default 1 MiB) accepted from OAuth endpoints (token, introspection, userinfo, discovery, JWKS). Curl aborts the transfer early when `Content-Length` exceeds this limit; a post-download guard catches chunked responses. Increase if a provider legitimately returns larger payloads

### State store

- `options(shinyOAuth.allow_non_atomic_state_store = TRUE)` – allow non-atomic `$get()` + `$remove()` fallback for shared state stores (e.g., `cachem::cache_disk()`) that do not implement `$take()`. By default, ‘shinyOAuth’ errors when a non-`cachem::cache_mem()` store lacks `$take()`, because the non-atomic fallback cannot guarantee single-use state consumption under concurrent access (TOCTOU replay window). Setting this option to `TRUE` downgrades the error to a one-time warning and allows the fallback to proceed. Not recommended for production without additional replay protection.

### Size caps

#### State envelope

- `options(shinyOAuth.state_max_token_chars = 8192)` – maximum allowed length of the base64url-encoded `state` query parameter
- `options(shinyOAuth.state_max_wrapper_bytes = 8192)` – maximum decoded byte size of the outer JSON wrapper (before parsing)
- `options(shinyOAuth.state_max_ct_b64_chars = 8192)` – maximum allowed length of the base64url-encoded ciphertext inside the wrapper
- `options(shinyOAuth.state_max_ct_bytes = 8192)` – maximum decoded byte size of the ciphertext before attempting AES-GCM decrypt

These prevent maliciously large state parameters from causing excessive CPU or memory usage during decoding and decryption.

#### Callback query

- `options(shinyOAuth.callback_max_code_bytes = 4096)` – maximum byte length of the `code` query parameter
- `options(shinyOAuth.callback_max_state_bytes = 8192)` – maximum byte length of the `state` query parameter (outer token string)
- `options(shinyOAuth.callback_max_error_bytes = 256)` – maximum byte length of the `error` query parameter
- `options(shinyOAuth.callback_max_error_description_bytes = 4096)` – maximum byte length of the `error_description` query parameter
- `options(shinyOAuth.callback_max_error_uri_bytes = 2048)` – maximum byte length of the `error_uri` query parameter
- `options(shinyOAuth.callback_max_iss_bytes = 2048)` – maximum byte length of the `iss` query parameter (RFC 9207 issuer identification)
- `options(shinyOAuth.callback_max_query_bytes = <derived>)` – maximum total byte length of the raw callback query string (pre-parse guard)
- `options(shinyOAuth.callback_max_browser_token_bytes = 256)` – maximum byte length of the `browser_token` argument accepted by `handle_callback()`
- `options(shinyOAuth.callback_max_form_post_body_bytes = <derived>)` – maximum byte length of the raw `form_post` callback body before parsing
- `options(shinyOAuth.callback_max_form_post_handle_bytes = 128)` – maximum byte length of the transient `shinyOAuth_form_post` handle query parameter
- `options(shinyOAuth.callback_max_form_post_id_bytes = 256)` – maximum byte length of the transient `shinyOAuth_form_post_id` module-id query parameter

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

### Development/debugging

- `options(shinyOAuth.skip_browser_token = TRUE)` – skip browser cookie binding in tests or interactive sessions
- `options(shinyOAuth.skip_id_sig = TRUE)` – skip ID token signature verification in tests or interactive sessions
- `options(shinyOAuth.allow_unsigned_userinfo_jwt = TRUE)` – accept unsigned (`alg=none`) UserInfo JWTs in tests or interactive sessions; outside those contexts ‘shinyOAuth’ errors instead of honoring it
- `options(shinyOAuth.debug = TRUE)` – re‑raise errors during token exchange
- `options(shinyOAuth.expose_error_body = TRUE)` – include sanitized HTTP bodies (may reveal details)

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

## Browser cookie & preventing XSS

`oauth_module_server()` binds the browser and server session with a short‑lived cookie that must be readable from client‑side JavaScript to bridge values into Shiny.

The cookie ensures that the same browser which initiated login is the one receiving the callback. This specifically prevents an attack where an attacker tricks a user into clicking a link which initiates login for the attacker’s account, confusing the user into logging in as the attacker (login confusion).

The cookie is set with the `HttpOnly` flag disabled so that it can be read by JavaScript. This is necessary to bridge the cookie value into Shiny. However, this means that if your app has XSS vulnerabilities, an attacker could read the cookie too.

While this is a relatively limited attack vector, you should still take care to prevent XSS vulnerabilities in your app. An important mitigation is to sanitize user inputs before rendering them in the UI (e.g., using `htmltools::htmlEscape()`).

## Multi‑process deployments: share state store, key, and policy

When you run multiple Shiny R processes (e.g., multiple workers, Shiny Server Pro, RStudio Connect, Docker/Kubernetes replicas, or any non‑sticky load balancer), you must ensure that:

- All workers share the same state store with atomic single-use semantics. Use `custom_cache()` with an atomic `$take()` method backed by a shared store (e.g., Redis `GETDEL`, SQL `DELETE ... RETURNING`). Plain `cachem::cache_disk()` is not safe as a shared state store because its `$get()` + `$remove()` are not atomic and may allow replay attacks under concurrent access; the default `cachem::cache_mem()` is per‑process only and is not shared. See `?custom_cache` for details on implementing `$take()`;
- All workers share the same state key (e.g., read from environment variable; by default, a random key is generated per client instance which is then not shared);
- All workers use the same effective `OAuthClient` / `OAuthProvider` settings which are included in the fingerprint used for state binding.

This is because during the authorization code + PKCE flow, ‘shinyOAuth’ creates an encrypted “state envelope” which is stored in a cache (the state_store) and echoed back via the `state` query parameter. The envelope is sealed with AES‑GCM using your state_key. If the callback lands on a different worker than the one that initiated login, that worker must be able to both read the cached entry and decrypt the envelope using the same key. If workers have different keys, decryption will fail and the login flow will abort with a state error.

When providing a custom state key, please ensure it has high entropy (minimum 32 characters or 32 raw bytes; recommended 64–128 characters) to prevent offline guessing attacks against the encrypted state. Do not use short or human‑memorable passphrases.

## Security checklist

Below is a checklist of things you may want to think about when bringing your app to production:

- Use HTTPS everywhere in production
- Verify issuer used in your provider is correct
- In your `OAuthClient` and `OAuthProvider`, set as many of the security options as your provider supports
- Have your `OAuthClient` request the minimum scopes necessary; give your app registration only the permissions it needs
- Do not show `$error_description` to your users; never expose tokens in UI or logs
- Keep secrets safe in environment variables (e.g., `OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`)
- Sanitize user inputs before rendering them in the UI (e.g., using `htmltools::htmlEscape()`)
- Make use of audit logging (see `vignette("audit-logging", package = "shinyOAuth")`) and monitor these logs
- Use a provider which enforces strong authentication (e.g., multi-factor authentication)
- Set Content Security Policy (CSP) headers to restrict resource loading and mitigate XSS attacks; (requires middleware; can’t be done in Shiny)
- Log IP addresses of those accessing your app (requires middleware; can’t be done in Shiny)

While this R package has been developed with care and the OAuth 2.0/OIDC protocols contain many security features, no guarantees can be made in the realm of cybersecurity. For highly sensitive applications, consider a layered (‘defense-in-depth’) approach to security (for example, adding an IP whitelist as an additional safeguard).