OAuth 2.0 and JWT authentication are the dominant patterns for securing mobile APIs, but testing them correctly requires specialized knowledge of token lifecycle management and security edge cases. According to the OWASP API Security Top 10 2023, broken authentication and authorization remain the top vulnerabilities in APIs, with JWT implementation flaws directly responsible for high-profile breaches including several fintech app incidents. According to a study by Auth0 (now Okta), 63% of mobile apps have at least one authentication vulnerability in their token handling code. For QA engineers, testing OAuth/JWT flows means validating the complete token lifecycle: authorization code exchange, access token validation, refresh token rotation, scope enforcement, and revocation — across multiple grant types and edge cases.

TL;DR: OAuth/JWT testing for mobile apps must cover: full authorization code flow (PKCE for mobile), access token validation (signature, expiry, audience), refresh token rotation, scope enforcement (accessing resources without required scopes returns 403), token revocation, and security attacks (algorithm confusion, JWT none algorithm, expired token acceptance).

OAuth 2.0 Flows for Mobile

Authorization Code Flow with PKCE

PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks, making it essential for mobile apps.

Flow Diagram:

1. App generates code_verifier + code_challenge
2. Redirect to OAuth provider with code_challenge
3. User authenticates
4. Provider redirects with authorization_code
5. App exchanges code + code_verifier for tokens

Android OAuth Implementation

// Using AppAuth library
dependencies {
    implementation("net.openid:appauth:0.11.1")
}

class OAuthManager(private val context: Context) {
    private val serviceConfig = AuthorizationServiceConfiguration(
        Uri.parse("https://accounts.example.com/authorize"),
        Uri.parse("https://accounts.example.com/token")
    )

    fun startAuthFlow(activity: Activity) {
        val authRequest = AuthorizationRequest.Builder(
            serviceConfig,
            CLIENT_ID,
            ResponseTypeValues.CODE,
            Uri.parse("myapp://oauth-callback")
        )
        .setCodeVerifier(CodeVerifierUtil.generateRandomCodeVerifier())
        .setScope("openid profile email")
        .build()

        val authService = AuthorizationService(context)
        val intent = authService.getAuthorizationRequestIntent(authRequest)

        activity.startActivityForResult(intent, AUTH_REQUEST_CODE)
    }

    fun handleAuthResponse(data: Intent, callback: (TokenResponse) -> Unit) {
        val authResponse = AuthorizationResponse.fromIntent(data)
        val authException = AuthorizationException.fromIntent(data)

        if (authResponse != null) {
            val tokenRequest = authResponse.createTokenExchangeRequest()

            AuthorizationService(context).performTokenRequest(tokenRequest) { response, exception ->
                if (response != null) {
                    callback(response)
                }
            }
        }
    }
}

Testing OAuth Flow

class OAuthFlowTest {

    @Test
    fun testAuthorizationCodeExchange() = runTest {
        val mockAuthServer = MockWebServer()
        mockAuthServer.start()

        // Mock token endpoint
        mockAuthServer.enqueue(MockResponse()
            .setResponseCode(200)
            .setBody("""
                {
                  "access_token": "mock_access_token",
                  "refresh_token": "mock_refresh_token",
                  "expires_in": 3600,
                  "token_type": "Bearer"
                }
            """.trimIndent())
        )

        val oauthManager = OAuthManager(context)

        val result = oauthManager.exchangeAuthCode("mock_auth_code")

        assertNotNull(result.accessToken)
        assertEquals("Bearer", result.tokenType)

        mockAuthServer.shutdown()
    }

    @Test
    fun testInvalidAuthorizationCode() = runTest {
        val mockAuthServer = MockWebServer()

        mockAuthServer.enqueue(MockResponse()
            .setResponseCode(400)
            .setBody("""
                {
                  "error": "invalid_grant",
                  "error_description": "Invalid authorization code"
                }
            """.trimIndent())
        )

        val exception = assertThrows<OAuthException> {
            oauthManager.exchangeAuthCode("invalid_code")
        }

        assertEquals("invalid_grant", exception.error)
    }
}

“OAuth and JWT testing is where you earn your security credibility as a QA engineer. Getting token validation right — especially edge cases like expired tokens, revoked tokens, and algorithm confusion attacks — is non-trivial and critically important.” — Yuri Kan, Senior QA Lead

JWT Token Testing

Token Structure Validation

data class JWTToken(
    val header: Header,
    val payload: Payload,
    val signature: String
) {
    data class Header(
        val alg: String,
        val typ: String
    )

    data class Payload(
        val sub: String,
        val exp: Long,
        val iat: Long,
        val iss: String,
        val aud: String
    )
}

class JWTValidator {
    fun validate(token: String): Boolean {
        val parts = token.split(".")
        if (parts.size != 3) return false

        val payload = decodePayload(parts[1])

        // Check expiration
        if (payload.exp * 1000 < System.currentTimeMillis()) {
            throw TokenExpiredException()
        }

        // Verify signature
        return verifySignature(parts[0], parts[1], parts[2])
    }
}

@Test
fun testJWTValidation() {
    val validToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." // Full JWT
    val validator = JWTValidator()

    assertTrue(validator.validate(validToken))
}

@Test
fun testExpiredToken() {
    val expiredToken = generateExpiredToken()

    assertThrows<TokenExpiredException> {
        validator.validate(expiredToken)
    }
}

Token Refresh Flow

Automatic Token Refresh

class TokenManager(private val authService: AuthService) {
    private var accessToken: String? = null
    private var refreshToken: String? = null
    private var expiresAt: Long = 0

    suspend fun getValidAccessToken(): String {
        if (isTokenExpired()) {
            refreshAccessToken()
        }

        return accessToken ?: throw NoTokenException()
    }

    private fun isTokenExpired(): Boolean {
        return System.currentTimeMillis() >= expiresAt - REFRESH_THRESHOLD
    }

    private suspend fun refreshAccessToken() {
        val response = authService.refreshToken(refreshToken!!)

        accessToken = response.accessToken
        refreshToken = response.refreshToken
        expiresAt = System.currentTimeMillis() + (response.expiresIn * 1000)
    }
}

@Test
fun testAutomaticTokenRefresh() = runTest {
    val mockServer = MockWebServer()

    // Mock refresh endpoint
    mockServer.enqueue(MockResponse()
        .setResponseCode(200)
        .setBody("""
            {
              "access_token": "new_access_token",
              "refresh_token": "new_refresh_token",
              "expires_in": 3600
            }
        """.trimIndent())
    )

    val tokenManager = TokenManager(authService)

    // Set expired token
    tokenManager.setToken("expired_token", expiresAt = System.currentTimeMillis() - 1000)

    // Should automatically refresh
    val validToken = tokenManager.getValidAccessToken()

    assertEquals("new_access_token", validToken)
}

Interceptor for Automatic Refresh

class AuthInterceptor(private val tokenManager: TokenManager) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val original = chain.request()

        val token = runBlocking {
            tokenManager.getValidAccessToken()
        }

        val authorized = original.newBuilder()
            .header("Authorization", "Bearer $token")
            .build()

        val response = chain.proceed(authorized)

        // Handle 401 Unauthorized
        if (response.code == 401) {
            response.close()

            // Force refresh and retry
            tokenManager.forceRefresh()
            val newToken = runBlocking { tokenManager.getValidAccessToken() }

            val retryRequest = original.newBuilder()
                .header("Authorization", "Bearer $newToken")
                .build()

            return chain.proceed(retryRequest)
        }

        return response
    }
}

@Test
fun testInterceptorRefreshOn401() = runTest {
    val mockServer = MockWebServer()

    // First request returns 401
    mockServer.enqueue(MockResponse().setResponseCode(401))

    // Refresh token request
    mockServer.enqueue(MockResponse()
        .setResponseCode(200)
        .setBody("""{"access_token": "new_token"}""")
    )

    // Retry succeeds
    mockServer.enqueue(MockResponse()
        .setResponseCode(200)
        .setBody("""{"data": "success"}""")
    )

    val client = OkHttpClient.Builder()
        .addInterceptor(AuthInterceptor(tokenManager))
        .build()

    val request = Request.Builder()
        .url(mockServer.url("/api/data"))
        .build()

    val response = client.newCall(request).execute()

    assertEquals(200, response.code)
}

Biometric Authentication Testing

Android Biometric Prompt

class BiometricAuthManager(private val context: FragmentActivity) {
    fun authenticate(onSuccess: () -> Unit, onFailure: (String) -> Unit) {
        val executor = ContextCompat.getMainExecutor(context)

        val biometricPrompt = BiometricPrompt(
            context,
            executor,
            object : BiometricPrompt.AuthenticationCallback() {
                override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                    onSuccess()
                }

                override fun onAuthenticationFailed() {
                    onFailure("Authentication failed")
                }

                override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                    onFailure(errString.toString())
                }
            }
        )

        val promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle("Biometric login")
            .setSubtitle("Log in using your biometric credential")
            .setNegativeButtonText("Use account password")
            .build()

        biometricPrompt.authenticate(promptInfo)
    }
}

@Test
fun testBiometricSuccess() = runTest {
    val biometricManager = BiometricAuthManager(activity)

    var authResult: String? = null

    biometricManager.authenticate(
        onSuccess = { authResult = "success" },
        onFailure = { authResult = "failed: $it" }
    )

    // Simulate biometric success (requires instrumented test with BiometricTestRule)
    onView(withId(R.id.biometric_prompt)).perform(click())

    assertEquals("success", authResult)
}

Secure Token Storage

Secure token storage is as critical as network security. While certificate pinning protects data in transit, proper token storage prevents unauthorized access at rest.

Android Keystore

class SecureTokenStorage(private val context: Context) {
    private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }

    private val cipher: Cipher
        get() {
            val cipher = Cipher.getInstance("AES/GCM/NoPadding")
            val secretKey = getOrCreateSecretKey()
            cipher.init(Cipher.ENCRYPT_MODE, secretKey)
            return cipher
        }

    private fun getOrCreateSecretKey(): SecretKey {
        if (!keyStore.containsAlias(KEY_ALIAS)) {
            val keyGenerator = KeyGenerator.getInstance(
                KeyProperties.KEY_ALGORITHM_AES,
                "AndroidKeyStore"
            )

            keyGenerator.init(
                KeyGenParameterSpec.Builder(
                    KEY_ALIAS,
                    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
                )
                .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
                .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
                .setUserAuthenticationRequired(true)
                .setUserAuthenticationValidityDurationSeconds(30)
                .build()
            )

            keyGenerator.generateKey()
        }

        return keyStore.getKey(KEY_ALIAS, null) as SecretKey
    }

    fun saveToken(token: String) {
        val cipher = cipher
        val encryptedData = cipher.doFinal(token.toByteArray())
        val iv = cipher.iv

        // Save encrypted data and IV to SharedPreferences
        context.getSharedPreferences("auth", Context.MODE_PRIVATE)
            .edit()
            .putString("token", Base64.encodeToString(encryptedData, Base64.DEFAULT))
            .putString("iv", Base64.encodeToString(iv, Base64.DEFAULT))
            .apply()
    }

    fun getToken(): String? {
        val prefs = context.getSharedPreferences("auth", Context.MODE_PRIVATE)
        val encryptedData = prefs.getString("token", null) ?: return null
        val iv = prefs.getString("iv", null) ?: return null

        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        val secretKey = keyStore.getKey(KEY_ALIAS, null) as SecretKey
        cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, Base64.decode(iv, Base64.DEFAULT)))

        val decryptedData = cipher.doFinal(Base64.decode(encryptedData, Base64.DEFAULT))
        return String(decryptedData)
    }
}

@Test
fun testSecureTokenStorage() {
    val storage = SecureTokenStorage(context)
    val originalToken = "sensitive_access_token"

    storage.saveToken(originalToken)

    val retrievedToken = storage.getToken()

    assertEquals(originalToken, retrievedToken)
}

Best Practices

  1. Always use PKCE for mobile OAuth - Prevents code interception
  2. Store tokens securely - Use Android Keystore / iOS Keychain
  3. Implement automatic token refresh - Seamless user experience
  4. Test token expiration scenarios - Handle edge cases
  5. Use biometric authentication where possible - Enhanced security + UX
  6. Validate JWT claims - exp, iat, iss, aud
  7. Handle OAuth errors gracefully - Invalid grants, network failures

For a complete overview of modern mobile testing practices, including OAuth testing integration, check our Mobile Testing 2025 guide. Also explore API Testing Mastery for comprehensive API testing strategies.

Conclusion

OAuth 2.0 and JWT testing for mobile requires:

  • PKCE flow implementation and testing
  • Token lifecycle management (refresh, expiration)
  • Secure storage validation
  • Biometric authentication integration
  • Error handling and edge cases

Proper authentication testing ensures security while maintaining seamless user experience across app lifecycles.

Official Resources

FAQ

What is PKCE and why is it required for mobile OAuth?

PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks in mobile apps where client secrets cannot be safely stored. Instead of a fixed client secret, PKCE uses a dynamically generated code_verifier and its SHA-256 hash (code_challenge). Every mobile OAuth implementation should use PKCE — it’s required by OAuth 2.1 for all public clients.

How do I test JWT validation correctly?

Test these cases: valid token (should succeed), expired token (should return 401), invalid signature (modified payload, should return 401), wrong algorithm (RS256 token validated as HS256 — algorithm confusion attack), none algorithm token (should always be rejected), wrong audience claim, and missing required claims. Use libraries like jwt.io for manual token manipulation during testing.

How do I test OAuth scope enforcement?

Create tokens with different scope subsets. Attempt to access resources requiring scopes not in the token. Verify: resources return 403 (not 401) for valid tokens with insufficient scope, scope errors include which scope is missing, and scope escalation (using a lower-privilege token to access higher-privilege endpoints) is properly blocked.

How do I test refresh token rotation?

Verify: refresh token can be used once to get a new access token and refresh token pair. Verify old refresh token is immediately invalidated after use. Test refresh token reuse detection (using the same refresh token twice should invalidate ALL tokens in the family and force re-authentication). Test refresh token expiry (long-lived refresh tokens expire as configured).

See Also