OAuth 2.0, PKCE and Token Management
Native Android applications are public clients: their binaries and runtime behavior can be inspected, so an embedded static client secret cannot be confidential. A robust session therefore uses a flow designed for public clients, sends the user to a trusted external browser, treats tokens as scoped and revocable credentials, and coordinates refresh as an explicit state transition.
OAuth 2.0 and OpenID Connect
OAuth 2.0 is a framework for delegated authorization. It lets a client obtain limited access to a protected resource without collecting the resource owner's password. OAuth itself is not a login protocol.
OpenID Connect (OIDC) adds an identity layer on top of OAuth 2.0. Applications commonly use OIDC to sign a user in and OAuth access tokens to authorize API calls:
- An access token authorizes calls to a particular resource server for an audience and scope.
- An ID token communicates an authentication event and identity claims to the client.
- A refresh token can obtain a new access token according to provider policy.
These values are not interchangeable. An ID token is not a general API credential, and an API must not accept a token issued for another audience.
Native application authorization model
An installed Android application cannot keep a shared static secret from the device owner. A value packaged in the APK, resources, native code, or remote configuration available to every installation is a public client credential, even if obfuscated.
Native applications should use registered redirect URIs and flows intended for public clients. Redirect handling must prove that the response belongs to the initiating application as strongly as the platform and provider allow, using claimed HTTPS redirects or carefully registered custom schemes. The authorization service must validate the exact redirect URI.
A backend-for-frontend or another confidential server component is a different trust environment because it can protect service credentials and authenticate server-to-server connections. Adding such a backend changes token ownership and API architecture; it does not make an APK-held secret confidential.
Authorization Code Flow with PKCE
Proof Key for Code Exchange binds an authorization request to the client instance that started it:
- Generate a high-entropy, one-time
code_verifier. - Derive
code_challenge = BASE64URL(SHA256(code_verifier)). - Open the authorization request with the challenge,
S256, and a freshstate. - The user authenticates and grants access at the authorization service.
- The app receives a short-lived, one-time authorization code through a validated redirect.
- The app exchanges the code and original verifier for tokens.
- The authorization server verifies that the verifier produces the stored challenge.
An intercepted code is not useful without the verifier. Use S256, a cryptographically random verifier for every attempt, and retain it only for the active authorization transaction. state correlates request and response and helps prevent cross-request or CSRF-style substitution. In OIDC, a fresh nonce binds the authentication request to the ID token and must be validated.
PKCE does not validate redirect ownership or replace state, nonce, TLS, or backend authorization. Prefer a mature standards-compliant OAuth/OIDC library such as AppAuth when it fits the provider instead of manually assembling protocol requests and token validation.
Browser-based authentication
Native authorization should normally open the system browser or a browser-backed surface such as Custom Tabs. The application does not directly receive the user's password, the user can recognize the authorization origin, and the flow can reuse provider sessions, password managers, security keys, passkeys, and browser security controls.
A standards-compliant client library also handles discovery, redirect matching, PKCE encoding, and protocol error details more reliably than custom URI construction. The application still owns lifecycle state, cancellation, account selection, and safe persistence after the token response.
Why embedded WebView login is usually inappropriate
For authentication with an external identity provider, a WebView is an embedded user agent controlled by the application. The host app can inspect page content and credential input, while the user cannot reliably verify the real origin. Shared provider sessions, password managers, security keys, and passkeys may not work as expected, and providers may block embedded authorization.
This does not mean every WebView page is inherently insecure. The warning is specifically about using an embedded surface to collect credentials or run third-party authorization. Use the provider's supported external-browser flow unless an explicitly reviewed first-party authentication architecture requires something else.
Access tokens
Most access tokens are bearer credentials: possession is sufficient to use them. Send a token only to the intended HTTPS origin and API audience, in the Authorization header, never in a URL. URLs commonly reach histories, analytics, referrers, screenshots, and infrastructure logs.
Keep scopes narrow and lifetimes short enough for the product risk. Do not send one provider's token to unrelated hosts or assume a token is valid merely because it has not reached a locally decoded expiration time. Network logging, crash reports, analytics, and debug tools must redact authorization headers and token responses.
Refresh tokens
Refresh tokens usually live longer and have greater impact than access tokens. A provider may rotate them on every use, detect reuse of an old token, revoke a token family, bind them to a client or device signal, or decline to issue them at all.
The client must atomically adopt every new token set, including a replacement refresh token. Refresh denial is a normal terminal session state, not an exceptional condition to retry forever. Provider policy defines expiration, inactivity windows, revocation, and whether an old refresh token remains valid.
Token expiration
Two strategies are useful:
- Proactive refresh: refresh shortly before a known expiry to reduce failed foreground calls.
- Reactive refresh: respond to an authentication failure when the server rejects the credential.
A balanced design can use both. Account for clock skew and avoid refreshing on every request. The server remains authoritative, and a 401 does not prove that the token merely expired: it may be revoked, malformed, intended for another audience, or associated with an invalid session.
Secure token storage
An access token can remain memory-only when re-authentication after process death is acceptable. A refresh token normally needs more deliberate persistence if the product promises session restoration or background work. Use Keystore-backed encrypted persistence with a defined backup, invalidation, migration, and recovery policy rather than plain preferences.
Encryption reduces casual extraction and offline inspection. It cannot hide a token after it is loaded into a compromised process. Keep token sets isolated per account, replace access and refresh tokens atomically, and clear the entire selected session on logout. Storage architecture is covered in Android Keystore and Secure Storage.
Adding authorization to requests
An application interceptor can add the latest token to calls for the exact API HTTPS origin. It should not cache one token forever or attach credentials to redirects and unrelated origins:
interface TokenStore {
fun current(): TokenSet?
fun replace(tokens: TokenSet)
fun clear()
}
data class TokenSet(
val accessToken: String,
val refreshToken: String?,
)
private fun HttpUrl.hasSameOrigin(other: HttpUrl): Boolean =
scheme == other.scheme &&
host == other.host &&
port == other.port
class AccessTokenInterceptor(
private val apiOrigin: HttpUrl,
private val tokenStore: TokenStore,
) : Interceptor {
init {
require(apiOrigin.isHttps) { "API origin must use HTTPS" }
}
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (!request.url.hasSameOrigin(apiOrigin) || request.header("Authorization") != null) {
return chain.proceed(request)
}
val accessToken = tokenStore.current()?.accessToken
?: return chain.proceed(request)
return chain.proceed(
request.newBuilder()
.header("Authorization", "Bearer $accessToken")
.build(),
)
}
}
Bearer tokens should be scoped to the intended HTTPS origin - scheme, host, and port - rather than merely a matching hostname. This check is required even when application-wide cleartext policy is also configured.
TokenStore.current() should normally return a fast, thread-safe in-memory snapshot. Encrypted persistent storage should hydrate and atomically update that snapshot rather than decrypting data from disk for every HTTP request. Logging interceptors must redact Authorization. Refresh orchestration belongs outside this interceptor so ordinary requests do not start independent refresh calls.
Refreshing tokens
Refresh is a session state transition:
- Read one internally consistent token set.
- Exchange the refresh token using a dedicated client that cannot trigger the same authenticator recursively.
- Classify the result as success, rejected credentials, or transient failure.
- Atomically replace both tokens on success.
- Clear or invalidate the session when the provider definitively rejects refresh.
- Keep the session for a transient network failure and surface a retryable result.
Persisting a new access token while retaining an obsolete rotated refresh token creates a broken session. Store the complete set as one versioned record or transaction.
Concurrent requests and single-flight refresh
Several parallel calls can receive 401 for the same token. If each refreshes independently, refresh-token rotation may cause the requests to invalidate one another, publish inconsistent token sets, create a refresh storm, or force logout.
A single-flight coordinator allows one refresh at a time. After entering the critical section, it checks whether another request already replaced the failed token. The example uses a JVM monitor because OkHttp's Authenticator is synchronous and runs off the main thread:
sealed interface RefreshOutcome {
data class Refreshed(val tokens: TokenSet) : RefreshOutcome
data object SessionRejected : RefreshOutcome
data object TemporarilyUnavailable : RefreshOutcome
data object NotRefreshable : RefreshOutcome
}
interface AuthApi {
fun refresh(refreshToken: String): RefreshOutcome
}
class TokenRefreshCoordinator(
private val tokenStore: TokenStore,
private val authApi: AuthApi,
) {
private val lock = Any()
fun refreshAfter(failedAccessToken: String): RefreshOutcome {
return synchronized(lock) {
val current = tokenStore.current()
?: return@synchronized RefreshOutcome.NotRefreshable
if (current.accessToken != failedAccessToken) {
return@synchronized RefreshOutcome.Refreshed(current)
}
val refreshToken = current.refreshToken
?: return@synchronized RefreshOutcome.NotRefreshable
when (val result = authApi.refresh(refreshToken)) {
is RefreshOutcome.Refreshed -> {
tokenStore.replace(result.tokens)
result
}
RefreshOutcome.SessionRejected -> {
tokenStore.clear()
result
}
RefreshOutcome.TemporarilyUnavailable,
RefreshOutcome.NotRefreshable -> result
}
}
}
}
AuthApi must use a client without this authenticator to avoid recursion. The explicit outcome preserves the distinction between a rejected session, a temporary failure that must not clear persisted credentials, and a session that has no refresh token. The monitor-based example intentionally blocks waiting callers while one refresh is in progress. A production implementation may represent the in-flight refresh as a shared result or future so callers wait for the same operation without coupling unrelated session work to the same monitor. It should also support cancellation and timeouts and make persistence atomic.
OkHttp interceptor vs authenticator
An interceptor modifies outgoing requests and is a good place to attach the current authorization header for a known HTTPS origin. It should not blindly refresh and replay calls.
An authenticator reacts to server authentication challenges such as 401 and may build a follow-up request. OkHttp can invoke it concurrently, so it needs loop prevention and the shared refresh coordinator.
class AccessTokenAuthenticator(
private val apiOrigin: HttpUrl,
private val tokenStore: TokenStore,
private val refreshCoordinator: TokenRefreshCoordinator,
) : Authenticator {
init {
require(apiOrigin.isHttps) { "API origin must use HTTPS" }
}
override fun authenticate(route: Route?, response: Response): Request? {
if (!response.request.url.hasSameOrigin(apiOrigin) || responseCount(response) >= 2) {
return null
}
val authorization = response.request.header("Authorization") ?: return null
val failedToken = authorization.removePrefix("Bearer ")
if (failedToken == authorization) return null
val tokens = when (val outcome = refreshCoordinator.refreshAfter(failedToken)) {
is RefreshOutcome.Refreshed -> outcome.tokens
RefreshOutcome.SessionRejected,
RefreshOutcome.TemporarilyUnavailable,
RefreshOutcome.NotRefreshable -> return null
}
return response.request.newBuilder()
.header("Authorization", "Bearer ${tokens.accessToken}")
.build()
}
private fun responseCount(response: Response): Int {
var count = 1
var prior = response.priorResponse
while (prior != null) {
count++
prior = prior.priorResponse
}
return count
}
}
This abbreviated sample retries an authenticated request at most once, compares the rejected token with the latest snapshot before refreshing, reuses a token already refreshed by another call, and returns null when it cannot satisfy the challenge. A session without a refresh token is non-refreshable. Rejected credentials clear the session, while transient failures preserve it. The sample omits provider-specific errors, non-repeatable request bodies, cancellation, metrics, account switching, persistence implementation, and UI session events. Review whether replaying each API operation is safe.
Handling 401 Unauthorized
A finite decision flow prevents loops:
- Verify that the failed request actually used an access token for the expected HTTPS origin.
- Stop if the request has already been retried.
- Reuse a newer token if another request already refreshed the session.
- Refresh only when a valid refreshable session exists.
- Retry the original operation once with the new token.
- End the session when refresh credentials are rejected.
- Surface a transient network failure differently from invalid credentials.
401 can mean a missing, expired, revoked, malformed, incorrectly signed, or wrong-audience token, or a provider-specific invalid session. Insufficient permission is commonly 403, but the backend contract is authoritative. Do not refresh forever or convert every authorization failure into logout without classifying it.
Logout and token revocation
Logout has several related but distinct parts:
- stop attaching the selected token set to new requests;
- atomically clear persisted and in-memory session state;
- cancel or isolate in-flight authenticated work and account-scoped caches;
- revoke refresh or access tokens at the authorization service when supported;
- optionally invoke the provider's OIDC end-session behavior.
Local app logout, token revocation, and identity-provider browser logout are not the same operation. Clearing application tokens does not necessarily clear the user's system-browser provider session. Define the expected UX and privacy behavior explicitly, especially on shared devices and with multiple accounts.
Process death and session restoration
Persist only the token state required by product behavior. On restart, decrypt one versioned record, verify that access and refresh tokens belong to one account and session, and refresh or require authentication if the state is incomplete or rejected.
Authorization codes, state, nonce, and PKCE verifiers are temporary transaction data. Retain them only while a specific flow can complete, bind the redirect to that flow, and clear abandoned or consumed state. Multiple-account applications need account-scoped token records and an atomic selected-account transition.
JWT limitations
JWT is a token format, not an authorization architecture, and not every OAuth token is a JWT. Base64url-decoding a JWT only parses readable claims; it does not validate signature, issuer, audience, expiry, nonce, or authorization.
The backend must validate access tokens and enforce resource permissions. The Android client must not grant security-sensitive capability based on an unverified decoded claim. Signed JWTs are normally encoded, not encrypted, so sensitive plaintext does not belong in claims merely because the token has a signature.
JWT does not automatically solve revocation or logout. For ID tokens, use a standards-compliant OIDC library and provider metadata rather than ad hoc parsing or signature logic.
Common implementation mistakes
- Embedding a permanent client secret in the APK.
- Collecting an external provider's password inside the application or WebView.
- Omitting PKCE, reusing a verifier, or using weak randomness.
- Failing to validate the exact redirect,
state, or OIDCnonce. - Storing tokens in plain preferences or logging headers and token responses.
- Sending a bearer token to an unrelated host or placing it in a URL.
- Refreshing on every
401without classifying the failure. - Starting parallel refreshes with a rotating refresh token.
- Creating an authenticator loop or replaying a request without a bound.
- Updating access and refresh tokens non-atomically.
- Retaining tokens, caches, or in-flight work after logout or account switch.
- Trusting decoded JWT claims without standards-compliant validation.
Practical architecture summary
Browser / Custom Tab
-> Authorization service
-> Validated redirect to Android app
-> Authorization code + PKCE exchange
-> Atomic token store
-> OkHttp request authorization
-> Single-flight refresh
-> Atomic session update or logout
The core principles are stable: treat the app as a public client, embed no confidential client secret, use Authorization Code Flow with PKCE in an external browser, keep access tokens short-lived, protect refresh-token persistence, coordinate one bounded refresh, and leave authoritative authorization to the backend.