aar

Room for All Your Overdue Potential 📚

1. The Beginning

In 2025, I was on a mission to add as many library cards as possible to Libby to accelerate my audiobook addiction. When I added my 39th library card, Libby started failing my requests. After a tortuous few days/weeks of debugging, I determined that the JWT token which Libby uses to store data was now too big for their nginx server to handle. 💥

I wrote some tools. I determined that the few thousand tags I had been painstakingly applying to books was lost, at least to me – I was unable to re-authenticate with Libby using my now compromised JWT. I could never get the full list. I mourned my loss, wrote a blog post in November, reset my account, re-added some (not all) library cards, decided to stop using tags if they were going to be a fragile mechanism and moved on with my life.

At some point this year, Libby either increased the header limitations of their nginx deployment AND/OR updated their JWT format. Probably both! Suddenly, I was able to re-authenticate with the broken JWT and export all my tags! I finished the tools I had started, used them to export and then re-import those tags into the account I was currently using and considered myself lucky!

Then I saw the post about the partner libraries… If Libby was adding partner libraries for everyone…

OH NO!

So back in I went. Will the addition of potentially dozens of partner libraries I had been avoiding once again throw me over the limit?!? Enquiring minds (me) wanted to know!

TL;DR: NOTHING TO WORRY ABOUT.

Libby changed the shape of the data they store in their JWT!

2. Prior JWT — the original issue…

The original JWT embedded every linked card in a verbose chip.accounts[] structure. Each card was represented as a named account object, approximately like this:

{
  "ag": 123,
  "id": "account-id",
  "typ": "library",
  "cards": [
    {
      "id": "card-id",
      "name": "My card name",
      "lib": {
        "id": "website-id",
        "key": "library-slug"
      }
    }
  ]
}

3. What the new JWT contains

The refreshed identity JWT still includes a grant for every linked card, but uses chip.cards[]: a compact positional tuple.

[
  "account-id",
  "card-id",
  "ils-id",
  false,
  "website-id",
  "library-slug"
]

4. Measurements from my tokens

I measured 89 real compact card grants across two refreshed Libby identities (46 cards and 43 cards).

The JWT has a fixed non-card envelope of about 610 bytes: its signed header, signature, and claims such as issuer, expiry, and chip identifiers. A useful approximation is:

JWT size ≈ 610 bytes + (number of cards × bytes per card)

For comparison, the observed legacy card representation was:

5. Capacity: legacy format and legacy limit vs. new format and current limit

The legacy-format “typical” figure uses its observed approximate cost of 167 bytes/card. The new figures use the measured distribution above.

A typical account has roughly eight times the theoretical card headroom: about 45 cards in the former 8 KiB/legacy-format world, versus about 382 cards using the current compact JWT under the current Libby-core header limit.

6. The limit test

The relevant request carries its JWT like this:

curl -sS -D- "https://sentry.libbyapp.com/chip/sync" \
  -H "Accept: application/json" \
  -H "Authorization: Bearer <YOUR_JWT>"

A malformed-but-small bearer reaches the application and is rejected as unauthenticated:

curl -sS -D- "https://sentry.libbyapp.com/chip/sync" \
  -H "Accept: application/json" \
  -H "Authorization: Bearer not-a-valid-jwt"

# HTTP/1.1 403 Forbidden
# {"result":"missing_chip"}

The request-header boundary was tested by increasing an invalid bearer value. The behavior changes sharply:

Authorization line length: 32,766 bytes → 403 Authorization line length: 32,767 bytes → 400

The difference matters:

7. What this means for people adding cards

8. Closing

My original failure was real, but the conditions behind it have materially improved. Libby still carries linked-card grants inside its identity token; the change is that those grants are now encoded more efficiently, and Libby accepts a substantially larger authorization header. For ordinary multi-library users, the practical danger zone has moved from “a few dozen cards may be risky” to “hundreds of normally sized cards.”