Overview
This vignette describes what happens when a user signs in through
oauth_module_server(): how shinyOAuth builds the
authorization request, validates the callback and tokens, and manages
the authenticated Shiny session. For application setup and code
examples, see the usage vignette.
The package implements the OAuth 2.0 Authorization Code flow. The app
redirects the browser to the provider, where the user signs in and
authorizes the requested access. The provider returns an authorization
code to the app’s registered redirect_uri (the callback
URL). shinyOAuth exchanges this temporary code for tokens and completes
the configured checks before setting
auth[["authenticated"]] = TRUE.
OpenID Connect (OIDC) adds identity verification to this flow through a signed ID token. OAuth-only integrations such as GitHub and Spotify obtain user information from provider-specific APIs; an access token alone is not an identity assertion.
What happens during the authentication flow?
The steps below follow a login from the first page load through token refresh and logout. Each step describes the usual OAuth or OIDC behavior first, then the additional behavior for the relevant optional features.
1. First page load: set a browser token
On first page load, the module asks the browser to create a random token in origin- and tab-scoped session storage and pass it to Shiny as a private input. A separate random cookie marker must match the stored record. This lets shinyOAuth check later that the browser returning from the provider is the one that started login. This browser token is separate from the access and ID tokens issued by the provider.
The cookie contains only the marker, so a service on another port cannot read the binding token from a Cookie header or establish a binding by replacing the cookie. Origin-scoped storage separates schemes, hosts, and ports. Treat the whole hostname as trusted nonetheless: other services can disrupt cookies, and same-origin scripts can access session storage. Use a dedicated hostname for authentication when co-hosted services are untrusted.
Each new transaction has its own cookie marker; separate tabs and application callback routes keep independent records. Complete login in the tab that started it.
The browser must support cookies, session storage, and Web Crypto. If
either storage mechanism cannot be written and read, the module reports
browser_cookie_error and stops login. Missing, expired, or
mismatched binding records are replaced; callbacks using the old binding
then fail validation. Cookies created by older package versions cannot
restore a binding without the origin record, so pending logins must be
restarted after upgrading.
Before each authorization request the server selects a fresh binding
token. The browser stores it and acknowledges the request without
duplicating the token in another input. The server rejects
client-selected replacements. Starting a second login for the same
module replaces the first pending binding. Private inputs are excluded
from URL and server bookmarks. Applications must not copy
auth[["browser_token"]] into their own bookmark values or
logs.
Wrap your UI in oauth_ui()
with the module ID and client
(oauth_ui(ui, id = "auth", client = client)). Query
callbacks are validated and stored before a redirect to a one-time URL;
only then does the application UI load. Responses disable caching and
referrer disclosure. Logical state still requires browser binding before
consumption.
2. Decide whether to start login
With auto_redirect = TRUE,
oauth_module_server() starts authorization for an
unauthenticated session. With auto_redirect = FALSE, the
application starts it by calling auth[["request_login"]](),
for example from a button observer.
3. Build the authorization request
The module constructs an authorization URL from the provider’s
auth_url and the client’s settings. The usual request
includes response_type=code, client_id,
redirect_uri, and the requested scope. For
OIDC, the scopes include openid. The module also creates
values that link this request to the callback and token exchange:
- State and a browser cookie link the callback to the pending login and its initiating browser. State is encrypted and protected against changes.
-
Proof Key for Code Exchange (PKCE) uses a secret
code_verifierand matchingcode_challengeto bind the authorization request to the later token exchange; the state and browser token provide the browser-session binding. - Nonce (OIDC) is a random value checked in the returned ID token to tie that token to the login request.
The module creates and checks these values automatically. OIDC
provider helpers enable PKCE and nonce by default. Public clients must
use PKCE, and S256 is the default PKCE method.
Fixed routing queries on the authorization endpoint are preserved.
Put protocol policy parameters such as prompt and
max_age in extra_auth_params, and use the
client properties for scopes, resource,
claims and response_mode. These parameters are
rejected in the endpoint query so they cannot bypass local policy checks
or be omitted from signed Request Objects or PAR. Matching generated
singleton values are emitted once; conflicting, repeated or disabled
protocol fields are rejected before use. Repeated resource
indicators remain supported through the client property. Parameter names
are case sensitive. The same composition checks apply to direct, PAR and
JAR browser URLs.
State storage and expiry
The encrypted state includes the client, provider, return address,
requested permissions, and creation time. The server also stores the
browser token and any PKCE verifier and nonce in
client@state_store. A successful callback uses this entry
once; expired or already-used entries cannot complete login.
Two time limits apply. state_payload_max_age limits the
age of the encrypted state, while the store’s max_age
limits how long the server entry remains. Both default to five minutes.
The browser cookie follows the store lifetime, with a five-minute
fallback when the store does not report a finite lifetime.
If callbacks can reach different R processes, those processes must
share the store, the state_key, and matching
provider/client settings. The shared store must read and delete an entry
atomically. See the deployment
guidance and custom_cache().
Signed and pushed authorization requests (JAR and PAR)
Some providers require the app to sign its authorization request, send it directly to the provider before redirecting, or both. These features change how the request is prepared and delivered; the user still signs in at the provider and the app still receives an authorization code.
With JWT-secured authorization requests (JAR), the module puts the
request parameters in a signed JSON Web Token (JWT), called a Request
Object. Optional encryption protects its contents as well.
request_object_mode = "request" includes that object in the
authorization URL. With "request_uri", the module publishes
the object at a URL that the provider fetches instead.
With Pushed Authorization Requests (PAR), the module first posts the
request from R to the provider’s par_url. The provider
returns a request_uri handle to include in the browser
redirect. This keeps the request details out of the browser URL and can
be combined with a signed Request Object. The module uses PAR when
par_url is configured, unless caller-managed
request_object_mode = "request_uri" is selected. A provider
that requires PAR does not allow that caller-managed alternative.
The two uses of request_uri differ: JAR by reference
points to an object published by the app; PAR uses a handle issued by
the provider. The default Shiny publisher uses an independent,
single-use handle served by oauth_ui() or
oauth_form_post_ui(), with no Shiny session token in the
URL. Configure the same client and state store for the UI and server.
request_uri_base_url sets the public app base URL. Handles
expire within 120 seconds and should be redacted from logs. Prefer PAR
for an opaque provider-issued handle. See Advanced security configuration for
setup.
The module handles request preparation automatically. prepare_call()
exposes it for custom integrations.
4. Redirect the browser to the provider
The module sends the browser to the authorization URL. For a regular
OAuth or OIDC request, its query string carries the parameters prepared
in the previous step. With JAR or PAR, it instead carries the Request
Object or request_uri, along with the outer parameters
required by the provider’s configuration.
If you set response_mode, the request also tells the
provider how to return the response. Leaving it unset uses the usual
query-string callback described in step 6.
5. User signs in and authorizes access
The provider handles authentication and asks the user to authorize the requested permissions. It may skip these prompts if the user already has an active session or has granted the requested access. The user’s credentials are entered at the provider, rather than in the Shiny app.
6. Provider returns the browser to the app
The provider redirects the browser to the registered
redirect_uri. A successful response normally carries
code and state in the URL’s query string, and
may include iss to identify the provider. If authorization
fails or the user declines access, the response carries error fields
instead of a code. The module validates the response before acting on
either result.
HTTP POST callbacks (form_post)
With response_mode = "form_post", the provider sends the
callback fields in an HTTP POST body. Because this arrives before a
Shiny session exists, wrap the UI with oauth_form_post_ui().
The wrapper checks the incoming request, stores the accepted callback
temporarily, and redirects the browser to a normal Shiny page using a
short-lived, single-use handle. The module then resumes callback
processing. The wrapper includes the oauth_ui() setup from
step 1.
Signed authorization responses (JARM)
JWT Secured Authorization Response Mode (JARM) returns the callback
fields inside a signed, optionally encrypted JWT in a
response parameter. response_mode = "jwt" or
"query.jwt" returns this through the query string;
"form_post.jwt" uses the POST wrapper. The response is
validated before its code, state, or error fields are used, as described
next. The advanced guide covers
configuration of both Form Post and JARM.
7. Validate the callback and login state
The module checks that the callback belongs to the pending login before exchanging the code for tokens. It checks the app address, verifies that state is authentic and fresh, compares the stored client and provider settings, and checks that the browser token matches. It waits for the browser-token input to reach Shiny if necessary, and rejects malformed or oversized callback values.
The default authorization-code budget is 8192 decoded bytes across direct, GET/POST bridge, module and JARM processing. Explicit field and aggregate limits still apply; oversized values are rejected without truncation. Large codes, state or JARM values can exceed a deployment’s proxy limits. Check the whole encoded callback when configuring those limits.
The state-store entry is consumed once. Missing, expired, or already-used entries cannot complete login. A Form Post handle must also be valid and unused; passing the earlier POST checks does not replace the browser-binding check in the Shiny session.
For JARM, shinyOAuth first verifies the response’s signature, issuer,
audience, expiry, and configured encryption requirements. Only then does
it process the contained callback fields. Use
oauth_module_server() for JARM; handle_callback()
accepts direct code/state callbacks only.
Even when the provider returns an error such as
access_denied, shinyOAuth checks the login state before
accepting that error as part of this session. Use
auth[["error"]] and
auth[["error_description"]] for diagnostics, and display an
application-specific message in the UI.
Browser binding and account validation
Browser binding links the response to the browser that started login; it does not establish which person should sign in at the provider. If someone obtains the complete login URL, they may complete that same request with another account and deliver the response to the initiating browser. Protect login URLs from disclosure and your app against cross-site scripting (XSS). Check the validated issuer and subject against your app’s expected account when that is part of your access policy.
Multiple authorization servers
If your app offers more than one provider, it must be able to tell
which one sent each response. Set authorization_server_mode
on each client:
-
"single"is the default for an app with one authorization server. -
"multi_issuer"identifies the provider in the response. Direct callbacks require advertised RFC 9207 support and a matchingiss; signed JARM responses supply issuer identification through their validated claims. -
"multi_redirect_uri"uses a distinct callback address for each server. Supply the complete set inauthorization_server_redirect_urisand useoauth_module_server(), which can check the address that received the callback.
These checks prevent responses from one provider being processed as
if they came from another. See oauth_client()
for the exact configuration contract.
Connect all providers to one HTTP callback dispatcher using a named registry:
clients <- list(auth_a = client_a, auth_b = client_b)
ui <- oauth_ui(shiny::fluidPage("My app"), clients = clients)
server <- function(input, output, session) {
auth_a <- oauth_module_server("auth_a", client_a)
auth_b <- oauth_module_server("auth_b", client_b)
}
shiny::shinyApp(ui, server, uiPattern = ".*")The registry accepts query and form-post clients together; it is also
available through
oauth_form_post_ui(..., clients = clients). Each client
must select one of the multi-server modes above. Distinct routes support
legacy providers without issuer response parameters. Shared routes
require multi_issuer with distinct configured issuers and
RFC 9207 iss or a signed JARM response. Encrypted JARM can
share an ordinary registry route when an outer iss
identifies one distinct configured issuer and matches the decrypted,
signed response. Without that routing hint, use distinct callback
routes. Issuer routing never replaces signature, transaction or
browser-binding validation. The dispatcher redirects to a clean bridge
URL before rendering the app. Nesting single-client wrappers does not
provide this routing. Supply the same trusted
request_uri_resolver used for single-provider deployments
when running behind a proxy.
8. Exchange the authorization code for tokens
Once callback validation succeeds, shinyOAuth sends a request to the
provider’s token_url with
grant_type=authorization_code. It includes the
authorization code, redirect_uri, the PKCE verifier when
enabled, and the credentials required by the app’s registration.
token_auth_style determines how the client identifies
itself, for example with a client secret or a private key.
The provider returns an access token, which authorizes API requests on the user’s behalf. It may also return a refresh token, allowing the app to obtain a replacement access token without another browser redirect. An OIDC response includes an ID token, which is checked in the next step.
The response must contain an access token and its
token_type. Malformed responses or unsupported token types
stop login. If the response reports fewer scopes than requested,
scope_validation controls the outcome: "warn"
(default) continues with a warning, "strict" stops login,
and "none" skips the check. This cannot grant a permission
the provider withheld.
Code exchange and refresh are not automatically retried after
ordinary network or HTTP failures: the provider may have already used
the code or rotated a refresh token. With DPoP, a provider nonce
challenge permits one retry with a fresh proof. See perform_resource_req()
for API-request retries.
Client certificates and certificate-bound tokens (mTLS)
With mutual TLS (mTLS, RFC 8705) client authentication, shinyOAuth
sends the configured client certificate on the connection to the token
endpoint. It prefers discovered mtls_endpoint_aliases where
available. The authentication styles are "tls_client_auth"
and "self_signed_tls_client_auth".
Certificate-bound access tokens are a separate policy: the API
requires the same certificate when the token is used. When the provider
advertises support and the client sets
mtls_certificate_bound_access_tokens = TRUE, shinyOAuth
also uses the certificate and mTLS endpoints for authorization-server
requests, even if the client uses another token_auth_style.
Before protected API or userinfo requests, it checks the token’s
certificate thumbprint (cnf[["x5t#S256"]]) against the
configured certificate. By default,
mtls_require_observed_cnf = TRUE requires this confirmation
to be visible. For opaque tokens whose binding is enforced only by the
servers, keep mtls_certificate_bound_access_tokens = TRUE
and set mtls_require_observed_cnf = FALSE. This permits
missing confirmation while retaining certificate presentation and
validation of any observed binding.
Tokens bound to a signing key (DPoP)
With dpop_private_key, shinyOAuth signs a proof for the
token request (Demonstrating Proof-of-Possession, DPoP, RFC 9449). The
provider issues a token bound to that key, and later API requests need a
matching proof. Configuring the key makes
dpop_require_access_token default to TRUE, so
a regular Bearer token does not satisfy the request.
If visible binding data includes a key thumbprint
(cnf[["jkt"]]), it must match the client’s key.
dpop_require_observed_cnf = TRUE additionally requires
binding data to be available; opaque tokens may need introspection for
this. Binding data decoded from a JWT access token is observed payload
data: shinyOAuth does not independently verify that access token’s
signature. This distinction also applies to mTLS certificate
thumbprints. See the advanced guide
for binding policies and setup.
Token requests retain redirect_uri by default for OAuth
2.0 interoperability; OAuth
2.1 draft 16 section 10.2 describes this compatibility behavior.
Selecting newer assertion or transport settings does not enable a
separate global protocol-version switch.
9. Validate the ID token (OIDC)
An OIDC ID token is a signed JSON Web Token (JWT) containing identity
fields, called claims. For example, sub identifies the user
within the issuer, iss identifies the issuer, and
aud identifies the intended recipient. Decoding these
fields does not verify the token’s authenticity.
OIDC helpers enable ID token validation. shinyOAuth checks the ID token before fetching userinfo or marking login complete. It checks that the provider signed it, that it was issued for this client, that it is still valid, and that it belongs to the login request.
The provider publishes public signing keys as a JSON Web Key
Set (JWKS). shinyOAuth fetches and caches these keys using the
provider’s configured issuer and key settings; provider
helpers supply the usual defaults.
Signature and claim checks
- The signature must use an allowed algorithm and a matching provider key. ID tokens must be signed; encrypted ID tokens are not supported.
-
issmust match the expected issuer;audmust include the client ID;submust identify a user. Additional audiences must be explicitly trusted. Ifazpis present, it must equal the client ID. -
exp(expiry) andiat(issue time) are required.nbf(valid from) is checked when present. Small clock differences are allowed byleeway, defaulting to 30 seconds.exp - iatis capped at 24 hours byshinyOAuth.max_id_token_lifetime. - The nonce must match when enabled. If
typis present in the token header, it must indicate a JWT, rather than another token format. - If
max_agewas requested,auth_timemust show a sufficiently recent login and must not be in the future beyond the clock allowance. - If
at_hashis present, it must match the access token. Setid_token_at_hash_required = TRUEto require it.
See oauth_provider()
for allowed signature algorithms and key policies. Unsigned tokens are
not accepted by the normal configuration.
Requested claims and authentication context (ACR)
Scopes request broad permissions. The OIDC claims
argument can request particular profile fields or values.
claims_validation controls whether an unmet request raises
a warning, stops login, or is ignored. When omitted, it defaults to
"warn" if claims contains enforceable
requirements and to "none" otherwise. Use
"strict" when your app requires those fields or values.
Checks on claims[["id_token"]] require validated ID token
content.
To require a particular login policy, such as multi-factor
authentication (MFA), set required_acr_values to your
provider’s Authentication Context Class Reference (ACR) identifiers. The
package sends the request and requires the validated token’s
acr claim to match one of them. The outgoing
acr_values parameter is a hint to the provider; validation
of the returned claim enforces the client’s requirement.
10. Build the OAuthToken object
The module stores the validated token response in an OAuthToken
object. It still completes any configured introspection and UserInfo
checks before making this the authenticated session’s
auth[["token"]].
The object holds:
-
access_token,token_type, and anyrefresh_tokenfor API access and renewal. -
expires_at, the access token’s expiry time. When the provider omitsexpires_in, shinyOAuth uses a finite fallback of 3,600 seconds, configurable throughshinyOAuth.default_expires_in. -
id_token, its decodedid_token_claims, andid_token_validated, which indicates whether the ID token has been verified. -
userinfo, populated when profile fields are fetched in step 12. Your app can then access them asauth[["token"]]@userinfo. -
granted_scopesandgranted_scopes_verified, describing the permissions associated with the token and whether the token response explicitly confirmed them. -
cnf, any observed key or certificate binding data for DPoP or mTLS.
11. Check whether the token is active (optional introspection)
Token introspection (RFC 7662) asks the provider whether a token is
still active. It is an optional request, enabled with
oauth_client(introspect = TRUE), and requires a configured
introspection_url. Login and subsequent module refreshes
then require a successful response with active = TRUE. The
request runs before the UserInfo fetch so it can supply binding data for
opaque mTLS or DPoP tokens.
introspection_checks can require additional fields such
as the subject, client ID, scopes, or token type; your provider must
return the fields you need. Checks that use the retrieved user profile
are completed after the next step, before the session becomes
authenticated.
12. Fetch user information (optional)
The provider’s UserInfo endpoint or profile API supplies fields such
as the user’s name and email address. The available fields depend on the
provider and the permissions granted. When
userinfo_required = TRUE, shinyOAuth fetches these fields
from userinfo_url using the access token. A failed required
fetch stops login.
With OIDC, this happens after ID token validation. When a validated
ID token and userinfo are both available, their sub values
must match. userinfo_id_token_match = TRUE also requires a
validated ID token to be available for that comparison. Requests in
claims[["userinfo"]] are checked according to
claims_validation, as with the ID token claims in step
9.
Signed UserInfo responses
Most providers return userinfo as JSON. Signed JWT userinfo is also
supported and verified against the provider’s public keys; encrypted
userinfo is not supported. Use userinfo_signed_jwt_required
to require this format and
userinfo_jwt_required_time_claims, for example
"exp", to require time claims. Present time claims are
checked even when not required. Signed userinfo uses the asymmetric
algorithms in userinfo_allowed_algs.
Discovery records whether the provider supports signed UserInfo, but
does not enable userinfo_signed_jwt_required: support at
the provider does not mean your app registration requests that response
format. Set the requirement explicitly when login must reject an
ordinary JSON profile response.
UserInfo with mTLS or DPoP
For certificate-bound access tokens, the UserInfo request uses the configured certificate and any mTLS UserInfo endpoint alias, after checking the token’s certificate binding. For DPoP tokens, the module attaches a proof signed with the client’s key. A resource-server nonce challenge permits one retry with a fresh proof; resource-server and token-server nonces are kept separately.
13. Mark the session as authenticated
After all required checks pass, the module sets
auth[["authenticated"]] = TRUE and exposes the token as
auth[["token"]]. Your reactive code can now use
shiny::req(auth[["authenticated"]]) before accessing the
profile or making API requests on the user’s behalf.
Pass the token to perform_resource_req()
for API requests. Also supply client = client when using
mTLS or DPoP so the helper can apply the certificate or proof required
by the token.
14. Clean up the callback URL and browser state
The module removes callback parameters from the browser address and
renews the browser cookie, so a future login starts with a fresh browser
token. The tab_title_ arguments in oauth_module_server()
can also remove a query-string suffix from the page title or set a
replacement title after the callback.
15. Manage the authenticated session
While the Shiny session is active, the module monitors token expiry and can refresh the token or require another login according to your settings.
Token refresh
The module can refresh access tokens before expiry when
refresh_proactively = TRUE and a refresh token is
available. A refreshed token replaces auth[["token"]], so
reactive code sees the new value. The provider can rotate refresh
tokens; shinyOAuth keeps the replacement when supplied. The replacement
token gets a new expiry time, using the same fallback as in step 10 if
expires_in is omitted.
Refresh uses the OAuth 2.0 refresh-token grant. Required UserInfo is
fetched again, and configured token introspection must succeed before
the module replaces the session token. See refresh_token()
for the fields that are updated and the validation rules applied to the
response.
ID tokens during refresh
An OIDC refresh response may omit the ID token, in which case the
original is kept. When a new ID token is returned, shinyOAuth checks
continuity with the original subject, issuer, and audience, as well as
auth_time and nonce continuity when applicable. A new ID
token without an original to compare against causes refresh to fail.
Full signature and claim validation also runs when
id_token_validation = TRUE.
Refreshing mTLS and DPoP tokens
Refresh requests use the configured certificate or DPoP proof as in the initial token exchange. A DPoP nonce challenge allows one fresh-proof retry; ordinary network or HTTP failures do not trigger a token-request retry.
A refreshed certificate-bound token needs fresh binding data from the new token or its introspection response. shinyOAuth does not carry the previous certificate thumbprint forward when the refresh response omits it.
Reauthentication
reauth_after_seconds sets a maximum time since
interactive login that token refresh cannot extend. This limit applies
when indefinite_session = FALSE (the default). With OIDC,
the module requests a fresh login using max_age=0 and
checks auth_time. With OAuth alone, it can end the local
session and request authorization again, but cannot require a fresh
provider login.
Expiry and refresh failures
By default, expiry or refresh failure clears the token and makes
auth[["authenticated"]] false. A refresh failure exposes
auth[["error"]] == "token_refresh_error" and diagnostic
details. With indefinite_session = TRUE, the module keeps
the token and authenticated state; auth[["token_stale"]]
signals expiry or a failed refresh. The provider may still reject API
calls with that token.
16. Logout and token revocation
auth[["logout"]]() clears the local login and attempts
to revoke both access and refresh tokens if the provider supports it. It
does not end the user’s login session at the provider.
revoke_on_session_end = TRUE also attempts revocation when
the Shiny session ends and requires a configured
revocation_url. Revocation can fail, for example if the
provider is unavailable; local cleanup still happens. These requests run
in the background only with async = TRUE.
The module also clears and renews the browser token for subsequent
logins. For direct token management, revoke_token()
lets you request revocation of an access or refresh token.
For a record of successful and failed steps throughout this flow, see Audit logging.