How can I send a POST request with a CSRF Token and a JSON body to a Django endpoint?
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-CSRFTokenheaderThe
csrftokencookie
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.
Why it works in browser but not Android
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.
What’s wrong in your code
This part:
val csrfCookieHeader = cookies?.substring(10,42)
You’re not actually storing/sending the cookie properly. You’re just slicing a string.
What you need to do
Extract the CSRF token properly from
Set-CookieSend:
header:
X-CSRFTokenheader:
Cookie: csrftoken=...
Fix
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
)
Important
Cookie name must be exactly
csrftokenHeader must be
X-CSRFTokenBoth values must match
About csrf_exempt
Don’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
I would recommend switching to a REST API library for production APIs, especially for mobile apps, such as django-rest-framework since they simplify authentication and already do a good bit of the heavy lifting; this is a great and permanent fix to your csrftoken issue.