I'm developing an Android Studio app which talks to a server running Django. I'm in the process of implementing the login process on the phone, however the server always returns 403 Forbidden to my POST requests, even with the X-CSRFToken header set.
In the app, an initial screen makes a GET request to the /phone/login endpoint, in order to get the CSRF Token from the Headers (I use the @ensure_csrf_cookie decorator on the Django view to ensure it's sent). Based on the response, the app navigates to a login screen where the user can input their details. That screen then makes a POST request to the /phone/login endpoint, with the token set in the headers and the details as JSON in the body (converted by gson). The server then gets this and processes it (not implemented).
I can see in the logs that the POST request is sent with the header and correct csrf token (as in, the same as I received from the server). Despite this, the server always responds with 403 Forbidden.
To make sure it wasn't a problem with the header name, I explicitly set CSRF_HEADER_NAME to the default value in my Django settings file. I've also tried sending an empty String in the body, and getting a csrf token with get_token(request) which I include in the LoginRequestBody object, like would be included with a html form, to the same result. The browser login page works fine, it's just the requests from the app getting this. I could obviously use @crsf_exempt on the view, but as it's still accessible from a browser wouldn't this be a security concern? Any help appreciated.
ViewModel.kt:
fun getLogin() { viewModelScope.launch { try { val response = CheckInApi.retrofitService.getLoggedIn() val body = response.body() ?: LoginResponse() if (response.code() == 200 ) { if (body.result == "LOGGED IN") { _uiState.update { currentState -> currentState.copy(result = AuthenticationState.SUCCESS)} } else if (body.result == "NOT LOGGED IN") { val cookies = response.headers().get("Set-Cookie") val csrfCookieHeader = cookies?.substring(10,42) ?: "No cookie" _uiState.update { currentState -> currentState.copy(result = AuthenticationState.LOGIN_REQUIRED,csrfCookieHeader = csrfCookieHeader)} } } } catch (e: IOException) { Log.d(TAG, e.toString()) } } } fun postLogin() { viewModelScope.launch { val loginRequestBody = LoginRequestBody(_uiState.value.username, _uiState.value.password) try { val result = CheckInApi.retrofitService.requestLogin( uiState.value.csrfCookieHeader, loginRequestBody) } catch (e: IOException) { Log.d(TAG, e.toString()) } } }
CheckInApiService.kt:
private val retrofit = Retrofit.Builder() .addConverterFactory(ScalarsConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create()) .baseUrl(BASE_URL) .build() interface CheckInApiService { @POST("phone/login/") suspend fun requestLogin(@Header("X-CSRFToken") token : String, @Body loginBody : LoginRequestBody): Response<String> @GET("phone/login/") suspend fun getLoggedIn(): Response<LoginResponse> } object CheckInApi { val retrofitService : CheckInApiService by lazy { retrofit.create(CheckInApiService::class.java) } }
views.py:
@ensure_csrf_cookie def login(request): if request.method not in ["GET", "POST"]: return HttpResponseNotAllowed(["GET", "POST"]) if request.user.is_authenticated: return HttpResponse("LOGGED IN") if request.method == "GET": response = { "result" : "NOT LOGGED IN" } return JsonResponse(response) else: # Log user in pass
You’re getting a 403 because Django CSRF needs two things, not just the header:
The X-CSRFToken header
The csrftoken cookie
Right now you’re only sending the header. Django checks that the token in the header matches the one in the cookie. If the cookie isn’t there → 403.
Browsers automatically:
store cookies from the GET response
send them back on POST
Your Android app doesn’t do that unless you explicitly handle cookies.
This part:
val csrfCookieHeader = cookies?.substring(10,42)
You’re not actually storing/sending the cookie properly. You’re just slicing a string.
Extract the CSRF token properly from Set-Cookie
Send:
header: X-CSRFToken
header: Cookie: csrftoken=...
Update your API:
@POST("phone/login/")
suspend fun requestLogin(
@Header("X-CSRFToken") token: String,
@Header("Cookie") cookie: String,
@Body loginBody: LoginRequestBody
): Response<String>
Then send it like:
val csrfToken = ... // extract properly
val cookie = "csrftoken=$csrfToken"
CheckInApi.retrofitService.requestLogin(
csrfToken,
cookie,
loginRequestBody
)
Cookie name must be exactly csrftoken
Header must be X-CSRFToken
Both values must match
csrf_exemptDon’t use it here. For login endpoints, that’s a bad idea.
If this is a mobile-only API, better long-term solution is:
use JWT or token auth
skip CSRF entirely