Originally created by @PandaUnit-TWL on GitHub (Mar 27, 2026).
Patch: Wire custom headers through all three HTTP stacks (fixes Cloudflare Zero Trust / reverse proxy auth)
Re: #254 — Custom header support for Cloudflare tunnels
Summary
The custom headers feature is already implemented in the ABS app codebase — ServerConnectionConfig has a customHeaders field (DeviceClasses.kt line 36–47), CustomHeadersModal.vue exists and works, and the data persists correctly. However, the feature was never fully wired up:
The UI button to access Custom Headers was never exposed on the server connect form
The native Android HTTP layer (ApiHandler.kt) never read customHeaders from the config
The audio player (PlayerNotificationService.kt) never sent custom headers on streaming requests
Several JS-layer requests (/status, /ping, OAuth, token refresh) didn't pass headers through
This meant that even if you manually set custom headers, they were silently dropped on most requests — making the app unusable behind header-authenticated reverse proxies like Cloudflare Zero Trust.
What the patch does
4 files changed, 33 insertions, 15 deletions.
1. components/connection/ServerConnectForm.vue
Adds a "Custom Headers" link on the server address entry screen (calls the existing addCustomHeaders() method → opens existing CustomHeadersModal.vue)
getServerAddressStatus() now passes this.serverConfig.customHeaders to getRequest()
connectToServer() now passes config.customHeaders to pingServerAddress()
oauthRequest() now includes this.serverConfig.customHeaders in the CapacitorHttp.get() call
2. plugins/nativeHttp.js
request() now spreads serverConnectionConfig.customHeaders into headers for all post-login API calls
refreshAccessToken() signature changed from (refreshToken, serverAddress) to (refreshToken, serverConnectionConfig) so custom headers are included on token refresh
handleTokenRefresh() updated to pass the full serverConnectionConfig instead of just .address
The ABS app has three separate HTTP stacks that all needed patching:
Layer
File
Handles
Capacitor/JS
nativeHttp.js, ServerConnectForm.vue
Login, browsing, API calls from the webview
Native Kotlin API
ApiHandler.kt
Native API calls including play requests, sync, library fetches
ExoPlayer streaming
PlayerNotificationService.kt
Actual audio file streaming via OkHttp/DefaultHttpDataSource
All three now pass custom headers consistently.
Known gap
WebSocket (plugins/server.js) does not currently pass extraHeaders to the socket.io connection. This could affect live sync features. If needed, the fix would be adding extraHeaders: DeviceManager.serverConnectionConfig?.customHeaders to the socket options in server.js line 36–41. I haven't hit issues with this yet, but flagging it for completeness.
Patch
diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt
index 815cd07..d1aac87 100644
--- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt
+++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt
@@ -505,6 +505,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
dataSourceFactory.setUserAgent(channelId)
+ val directPlayHeaders = hashMapOf("Authorization" to "Bearer ${DeviceManager.token}")
+ DeviceManager.serverConnectionConfig?.customHeaders?.let { directPlayHeaders.putAll(it) }
+ dataSourceFactory.setDefaultRequestProperties(directPlayHeaders)
mediaSource =
ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
.createMediaSource(mediaItems[0])
@@ -512,9 +515,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
AbsLogger.info("PlayerNotificationService", "preparePlayer: Playing HLS stream of item ${currentPlaybackSession?.mediaItemId}.")
val dataSourceFactory = DefaultHttpDataSource.Factory()
dataSourceFactory.setUserAgent(channelId)
- dataSourceFactory.setDefaultRequestProperties(
- hashMapOf("Authorization" to "Bearer ${DeviceManager.token}")
- )
+ val hlsHeaders = hashMapOf("Authorization" to "Bearer ${DeviceManager.token}")
+ DeviceManager.serverConnectionConfig?.customHeaders?.let { hlsHeaders.putAll(it) }
+ dataSourceFactory.setDefaultRequestProperties(hlsHeaders)
mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
}
mPlayer.setMediaSource(mediaSource)
diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt
index d25e744..294822b 100644
--- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt
+++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt
@@ -56,11 +56,13 @@ class ApiHandler(var ctx:Context) {
private fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
val address = config?.address ?: DeviceManager.serverAddress
val token = config?.token ?: DeviceManager.token
+ val customHeaders = config?.customHeaders ?: DeviceManager.serverConnectionConfig?.customHeaders
try {
- val request = Request.Builder()
+ val builder = Request.Builder()
.url("${address}$endpoint").addHeader("Authorization", "Bearer $token")
- .build()
+ customHeaders?.forEach { (key, value) -> builder.addHeader(key, value) }
+ val request = builder.build()
makeRequest(request, httpClient, cb)
} catch(e: Exception) {
e.printStackTrace()
@@ -73,14 +75,16 @@ class ApiHandler(var ctx:Context) {
private fun postRequest(endpoint:String, payload: JSObject?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
val address = config?.address ?: DeviceManager.serverAddress
val token = config?.token ?: DeviceManager.token
+ val customHeaders = config?.customHeaders ?: DeviceManager.serverConnectionConfig?.customHeaders
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = payload?.toString()?.toRequestBody(mediaType) ?: EMPTY_REQUEST
val requestUrl = "${address}$endpoint"
Log.d(tag, "postRequest to $requestUrl")
try {
- val request = Request.Builder().post(requestBody)
+ val builder = Request.Builder().post(requestBody)
.url(requestUrl).addHeader("Authorization", "Bearer ${token}")
- .build()
+ customHeaders?.forEach { (key, value) -> builder.addHeader(key, value) }
+ val request = builder.build()
makeRequest(request, null, cb)
} catch(e: Exception) {
e.printStackTrace()
@@ -93,10 +97,12 @@ class ApiHandler(var ctx:Context) {
private fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = payload.toString().toRequestBody(mediaType)
+ val customHeaders = DeviceManager.serverConnectionConfig?.customHeaders
try {
- val request = Request.Builder().patch(requestBody)
+ val builder = Request.Builder().patch(requestBody)
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
- .build()
+ customHeaders?.forEach { (key, value) -> builder.addHeader(key, value) }
+ val request = builder.build()
makeRequest(request, null, cb)
} catch(e: Exception) {
e.printStackTrace()
diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue
index e4abd33..3b1a892 100644
--- a/components/connection/ServerConnectForm.vue
+++ b/components/connection/ServerConnectForm.vue
@@ -42,6 +42,9 @@
<div class="flex justify-end items-center mt-6">
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
</div>
+ <div class="flex justify-center mt-3">
+ <a class="text-fg-muted text-sm cursor-pointer" @click="addCustomHeaders">Custom Headers</a>
+ </div>
</form>
<!-- username/password and auth methods -->
<template v-else>
@@ -298,6 +301,7 @@ export default {
try {
const response = await CapacitorHttp.get({
url: backendEndpoint,
+ headers: this.serverConfig.customHeaders || {},
disableRedirects: true,
webFetchExtra: {
redirect: 'manual'
@@ -454,7 +458,7 @@ export default {
...config
}
this.showForm = true
- var success = await this.pingServerAddress(config.address)
+ var success = await this.pingServerAddress(config.address, config.customHeaders)
this.processing = false
console.log(`[ServerConnectForm] pingServer result ${success}`)
if (!success) {
@@ -615,7 +619,7 @@ export default {
* HttpResponse.data is {isInit:boolean, language:string, authMethods:string[]}}>
*/
async getServerAddressStatus(address) {
- return this.getRequest(`${address}/status`)
+ return this.getRequest(`${address}/status`, this.serverConfig.customHeaders)
},
pingServerAddress(address, customHeaders) {
return this.getRequest(`${address}/ping`, customHeaders)
diff --git a/plugins/nativeHttp.js b/plugins/nativeHttp.js
index 76aa0e0..741172d 100644
--- a/plugins/nativeHttp.js
+++ b/plugins/nativeHttp.js
@@ -27,6 +27,9 @@ export default function ({ store, $db, $socket }, inject) {
headers = { ...headers, ...options.headers }
delete options.headers
}
+ if (serverConnectionConfig?.customHeaders) {
+ headers = { ...headers, ...serverConnectionConfig.customHeaders }
+ }
console.log(`[nativeHttp] Making ${method} request to ${url}`)
return CapacitorHttp.request({
@@ -76,7 +79,7 @@ export default function ({ store, $db, $socket }, inject) {
}
// Attempt to refresh the token
- const newTokens = await this.refreshAccessToken(refreshToken, serverConnectionConfig.address)
+ const newTokens = await this.refreshAccessToken(refreshToken, serverConnectionConfig)
if (!newTokens?.accessToken) {
console.error('[nativeHttp] Failed to refresh access token')
throw new Error('Failed to refresh access token')
@@ -116,11 +119,12 @@ export default function ({ store, $db, $socket }, inject) {
/**
* Refreshes the access token using the refresh token
* @param {string} refreshToken - The refresh token
- * @param {string} serverAddress - The server address
+ * @param {{ id: string, address: string, customHeaders: Object }} serverConnectionConfig - The server connection config
* @returns {Promise<Object|null>} - Promise that resolves with new tokens or null
*/
- async refreshAccessToken(refreshToken, serverAddress) {
+ async refreshAccessToken(refreshToken, serverConnectionConfig) {
try {
+ const serverAddress = serverConnectionConfig?.address
if (!serverAddress) {
throw new Error('No server address available')
}
@@ -131,7 +135,8 @@ export default function ({ store, $db, $socket }, inject) {
url: `${serverAddress}/auth/refresh`,
headers: {
'Content-Type': 'application/json',
- 'x-refresh-token': refreshToken
+ 'x-refresh-token': refreshToken,
+ ...(serverConnectionConfig?.customHeaders || {})
},
data: {}
})
Testing
Tested with a signed release APK against an Audiobookshelf instance behind Cloudflare Zero Trust using service token authentication (CF-Access-Client-Id / CF-Access-Client-Secret headers). Login, library browsing, and audio playback (both direct play and HLS streaming) all work correctly.
Based on master branch.
Copied repo as of late 3/26/26
Originally created by @PandaUnit-TWL on GitHub (Mar 27, 2026).
# Patch: Wire custom headers through all three HTTP stacks (fixes Cloudflare Zero Trust / reverse proxy auth)
**Re: #254** — Custom header support for Cloudflare tunnels
## Summary
The custom headers feature is already implemented in the ABS app codebase — `ServerConnectionConfig` has a `customHeaders` field (`DeviceClasses.kt` line 36–47), `CustomHeadersModal.vue` exists and works, and the data persists correctly. However, the feature was never fully wired up:
1. The UI button to access Custom Headers was never exposed on the server connect form
2. The native Android HTTP layer (`ApiHandler.kt`) never read `customHeaders` from the config
3. The audio player (`PlayerNotificationService.kt`) never sent custom headers on streaming requests
4. Several JS-layer requests (`/status`, `/ping`, OAuth, token refresh) didn't pass headers through
This meant that even if you manually set custom headers, they were silently dropped on most requests — making the app unusable behind header-authenticated reverse proxies like Cloudflare Zero Trust.
## What the patch does
**4 files changed, 33 insertions, 15 deletions.**
### 1. `components/connection/ServerConnectForm.vue`
- Adds a **"Custom Headers"** link on the server address entry screen (calls the existing `addCustomHeaders()` method → opens existing `CustomHeadersModal.vue`)
- `getServerAddressStatus()` now passes `this.serverConfig.customHeaders` to `getRequest()`
- `connectToServer()` now passes `config.customHeaders` to `pingServerAddress()`
- `oauthRequest()` now includes `this.serverConfig.customHeaders` in the `CapacitorHttp.get()` call
### 2. `plugins/nativeHttp.js`
- `request()` now spreads `serverConnectionConfig.customHeaders` into headers for all post-login API calls
- `refreshAccessToken()` signature changed from `(refreshToken, serverAddress)` to `(refreshToken, serverConnectionConfig)` so custom headers are included on token refresh
- `handleTokenRefresh()` updated to pass the full `serverConnectionConfig` instead of just `.address`
### 3. `android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt`
- `getRequest()` — reads `customHeaders` from config or `DeviceManager.serverConnectionConfig`, applies via `builder.addHeader()` loop
- `postRequest()` — same treatment (this is the play request fix — `/api/items/{id}/play` goes through here)
- `patchRequest()` — same treatment
### 4. `android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt`
- **Direct play path** (~line 507): creates `directPlayHeaders` HashMap with auth + custom headers, calls `setDefaultRequestProperties()`
- **HLS streaming path** (~line 515): creates `hlsHeaders` HashMap with auth + custom headers, calls `setDefaultRequestProperties()`
## Key architecture note
The ABS app has **three separate HTTP stacks** that all needed patching:
| Layer | File | Handles |
|---|---|---|
| Capacitor/JS | `nativeHttp.js`, `ServerConnectForm.vue` | Login, browsing, API calls from the webview |
| Native Kotlin API | `ApiHandler.kt` | Native API calls including play requests, sync, library fetches |
| ExoPlayer streaming | `PlayerNotificationService.kt` | Actual audio file streaming via OkHttp/DefaultHttpDataSource |
All three now pass custom headers consistently.
## Known gap
**WebSocket** (`plugins/server.js`) does **not** currently pass `extraHeaders` to the socket.io connection. This could affect live sync features. If needed, the fix would be adding `extraHeaders: DeviceManager.serverConnectionConfig?.customHeaders` to the socket options in `server.js` line 36–41. I haven't hit issues with this yet, but flagging it for completeness.
## Patch
```diff
diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt
index 815cd07..d1aac87 100644
--- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt
+++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt
@@ -505,6 +505,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
}
dataSourceFactory.setUserAgent(channelId)
+ val directPlayHeaders = hashMapOf("Authorization" to "Bearer ${DeviceManager.token}")
+ DeviceManager.serverConnectionConfig?.customHeaders?.let { directPlayHeaders.putAll(it) }
+ dataSourceFactory.setDefaultRequestProperties(directPlayHeaders)
mediaSource =
ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
.createMediaSource(mediaItems[0])
@@ -512,9 +515,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
AbsLogger.info("PlayerNotificationService", "preparePlayer: Playing HLS stream of item ${currentPlaybackSession?.mediaItemId}.")
val dataSourceFactory = DefaultHttpDataSource.Factory()
dataSourceFactory.setUserAgent(channelId)
- dataSourceFactory.setDefaultRequestProperties(
- hashMapOf("Authorization" to "Bearer ${DeviceManager.token}")
- )
+ val hlsHeaders = hashMapOf("Authorization" to "Bearer ${DeviceManager.token}")
+ DeviceManager.serverConnectionConfig?.customHeaders?.let { hlsHeaders.putAll(it) }
+ dataSourceFactory.setDefaultRequestProperties(hlsHeaders)
mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
}
mPlayer.setMediaSource(mediaSource)
diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt
index d25e744..294822b 100644
--- a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt
+++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt
@@ -56,11 +56,13 @@ class ApiHandler(var ctx:Context) {
private fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
val address = config?.address ?: DeviceManager.serverAddress
val token = config?.token ?: DeviceManager.token
+ val customHeaders = config?.customHeaders ?: DeviceManager.serverConnectionConfig?.customHeaders
try {
- val request = Request.Builder()
+ val builder = Request.Builder()
.url("${address}$endpoint").addHeader("Authorization", "Bearer $token")
- .build()
+ customHeaders?.forEach { (key, value) -> builder.addHeader(key, value) }
+ val request = builder.build()
makeRequest(request, httpClient, cb)
} catch(e: Exception) {
e.printStackTrace()
@@ -73,14 +75,16 @@ class ApiHandler(var ctx:Context) {
private fun postRequest(endpoint:String, payload: JSObject?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
val address = config?.address ?: DeviceManager.serverAddress
val token = config?.token ?: DeviceManager.token
+ val customHeaders = config?.customHeaders ?: DeviceManager.serverConnectionConfig?.customHeaders
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = payload?.toString()?.toRequestBody(mediaType) ?: EMPTY_REQUEST
val requestUrl = "${address}$endpoint"
Log.d(tag, "postRequest to $requestUrl")
try {
- val request = Request.Builder().post(requestBody)
+ val builder = Request.Builder().post(requestBody)
.url(requestUrl).addHeader("Authorization", "Bearer ${token}")
- .build()
+ customHeaders?.forEach { (key, value) -> builder.addHeader(key, value) }
+ val request = builder.build()
makeRequest(request, null, cb)
} catch(e: Exception) {
e.printStackTrace()
@@ -93,10 +97,12 @@ class ApiHandler(var ctx:Context) {
private fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = payload.toString().toRequestBody(mediaType)
+ val customHeaders = DeviceManager.serverConnectionConfig?.customHeaders
try {
- val request = Request.Builder().patch(requestBody)
+ val builder = Request.Builder().patch(requestBody)
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
- .build()
+ customHeaders?.forEach { (key, value) -> builder.addHeader(key, value) }
+ val request = builder.build()
makeRequest(request, null, cb)
} catch(e: Exception) {
e.printStackTrace()
diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue
index e4abd33..3b1a892 100644
--- a/components/connection/ServerConnectForm.vue
+++ b/components/connection/ServerConnectForm.vue
@@ -42,6 +42,9 @@
<div class="flex justify-end items-center mt-6">
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
</div>
+ <div class="flex justify-center mt-3">
+ <a class="text-fg-muted text-sm cursor-pointer" @click="addCustomHeaders">Custom Headers</a>
+ </div>
</form>
<!-- username/password and auth methods -->
<template v-else>
@@ -298,6 +301,7 @@ export default {
try {
const response = await CapacitorHttp.get({
url: backendEndpoint,
+ headers: this.serverConfig.customHeaders || {},
disableRedirects: true,
webFetchExtra: {
redirect: 'manual'
@@ -454,7 +458,7 @@ export default {
...config
}
this.showForm = true
- var success = await this.pingServerAddress(config.address)
+ var success = await this.pingServerAddress(config.address, config.customHeaders)
this.processing = false
console.log(`[ServerConnectForm] pingServer result ${success}`)
if (!success) {
@@ -615,7 +619,7 @@ export default {
* HttpResponse.data is {isInit:boolean, language:string, authMethods:string[]}}>
*/
async getServerAddressStatus(address) {
- return this.getRequest(`${address}/status`)
+ return this.getRequest(`${address}/status`, this.serverConfig.customHeaders)
},
pingServerAddress(address, customHeaders) {
return this.getRequest(`${address}/ping`, customHeaders)
diff --git a/plugins/nativeHttp.js b/plugins/nativeHttp.js
index 76aa0e0..741172d 100644
--- a/plugins/nativeHttp.js
+++ b/plugins/nativeHttp.js
@@ -27,6 +27,9 @@ export default function ({ store, $db, $socket }, inject) {
headers = { ...headers, ...options.headers }
delete options.headers
}
+ if (serverConnectionConfig?.customHeaders) {
+ headers = { ...headers, ...serverConnectionConfig.customHeaders }
+ }
console.log(`[nativeHttp] Making ${method} request to ${url}`)
return CapacitorHttp.request({
@@ -76,7 +79,7 @@ export default function ({ store, $db, $socket }, inject) {
}
// Attempt to refresh the token
- const newTokens = await this.refreshAccessToken(refreshToken, serverConnectionConfig.address)
+ const newTokens = await this.refreshAccessToken(refreshToken, serverConnectionConfig)
if (!newTokens?.accessToken) {
console.error('[nativeHttp] Failed to refresh access token')
throw new Error('Failed to refresh access token')
@@ -116,11 +119,12 @@ export default function ({ store, $db, $socket }, inject) {
/**
* Refreshes the access token using the refresh token
* @param {string} refreshToken - The refresh token
- * @param {string} serverAddress - The server address
+ * @param {{ id: string, address: string, customHeaders: Object }} serverConnectionConfig - The server connection config
* @returns {Promise<Object|null>} - Promise that resolves with new tokens or null
*/
- async refreshAccessToken(refreshToken, serverAddress) {
+ async refreshAccessToken(refreshToken, serverConnectionConfig) {
try {
+ const serverAddress = serverConnectionConfig?.address
if (!serverAddress) {
throw new Error('No server address available')
}
@@ -131,7 +135,8 @@ export default function ({ store, $db, $socket }, inject) {
url: `${serverAddress}/auth/refresh`,
headers: {
'Content-Type': 'application/json',
- 'x-refresh-token': refreshToken
+ 'x-refresh-token': refreshToken,
+ ...(serverConnectionConfig?.customHeaders || {})
},
data: {}
})
```
## Testing
Tested with a signed release APK against an Audiobookshelf instance behind Cloudflare Zero Trust using service token authentication (`CF-Access-Client-Id` / `CF-Access-Client-Secret` headers). Login, library browsing, and audio playback (both direct play and HLS streaming) all work correctly.
Based on `master` branch.
Copied repo as of late 3/26/26
adam
added the bug label 2026-04-24 23:57:48 +02:00
We are not reviewing AI generated PRs or issues at this time due to an increasing number of low quality PRs and limited reviewer time.
@nichwall commented on GitHub (Mar 28, 2026):
We are not reviewing AI generated PRs or issues at this time due to an increasing number of low quality PRs and limited reviewer time.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Originally created by @PandaUnit-TWL on GitHub (Mar 27, 2026).
Patch: Wire custom headers through all three HTTP stacks (fixes Cloudflare Zero Trust / reverse proxy auth)
Re: #254 — Custom header support for Cloudflare tunnels
Summary
The custom headers feature is already implemented in the ABS app codebase —
ServerConnectionConfighas acustomHeadersfield (DeviceClasses.ktline 36–47),CustomHeadersModal.vueexists and works, and the data persists correctly. However, the feature was never fully wired up:ApiHandler.kt) never readcustomHeadersfrom the configPlayerNotificationService.kt) never sent custom headers on streaming requests/status,/ping, OAuth, token refresh) didn't pass headers throughThis meant that even if you manually set custom headers, they were silently dropped on most requests — making the app unusable behind header-authenticated reverse proxies like Cloudflare Zero Trust.
What the patch does
4 files changed, 33 insertions, 15 deletions.
1.
components/connection/ServerConnectForm.vueaddCustomHeaders()method → opens existingCustomHeadersModal.vue)getServerAddressStatus()now passesthis.serverConfig.customHeaderstogetRequest()connectToServer()now passesconfig.customHeaderstopingServerAddress()oauthRequest()now includesthis.serverConfig.customHeadersin theCapacitorHttp.get()call2.
plugins/nativeHttp.jsrequest()now spreadsserverConnectionConfig.customHeadersinto headers for all post-login API callsrefreshAccessToken()signature changed from(refreshToken, serverAddress)to(refreshToken, serverConnectionConfig)so custom headers are included on token refreshhandleTokenRefresh()updated to pass the fullserverConnectionConfiginstead of just.address3.
android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.ktgetRequest()— readscustomHeadersfrom config orDeviceManager.serverConnectionConfig, applies viabuilder.addHeader()looppostRequest()— same treatment (this is the play request fix —/api/items/{id}/playgoes through here)patchRequest()— same treatment4.
android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.ktdirectPlayHeadersHashMap with auth + custom headers, callssetDefaultRequestProperties()hlsHeadersHashMap with auth + custom headers, callssetDefaultRequestProperties()Key architecture note
The ABS app has three separate HTTP stacks that all needed patching:
nativeHttp.js,ServerConnectForm.vueApiHandler.ktPlayerNotificationService.ktAll three now pass custom headers consistently.
Known gap
WebSocket (
plugins/server.js) does not currently passextraHeadersto the socket.io connection. This could affect live sync features. If needed, the fix would be addingextraHeaders: DeviceManager.serverConnectionConfig?.customHeadersto the socket options inserver.jsline 36–41. I haven't hit issues with this yet, but flagging it for completeness.Patch
Testing
Tested with a signed release APK against an Audiobookshelf instance behind Cloudflare Zero Trust using service token authentication (
CF-Access-Client-Id/CF-Access-Client-Secretheaders). Login, library browsing, and audio playback (both direct play and HLS streaming) all work correctly.Based on
masterbranch.Copied repo as of late 3/26/26
@PandaUnit-TWL commented on GitHub (Mar 27, 2026):
abs-custom-headers-full.patch
@nichwall commented on GitHub (Mar 28, 2026):
We are not reviewing AI generated PRs or issues at this time due to an increasing number of low quality PRs and limited reviewer time.