Skip to content

Build1 publisher2 min readPublished

Handing token signing to Keycloak leaves a FastAPI RAG service holding only public keys

GroundedDocs, a FastAPI RAG service, verifies Keycloak-signed tokens that expire in about 15 minutes and holds no key that can sign one. Keycloak now keeps the passwords and the signing key, so the identity provider becomes the system worth attacking.

The Engineer · Build desk

Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

Illustration accompanying Handing token signing to Keycloak leaves a FastAPI RAG service holding only public keys
Generated illustration

What happened

  • Before shipping, the author weighed a /login route that checked passwords against Postgres and signed a JWT, and conceded it would have worked.
  • With a signing secret in config, a thief holding that file could mint a valid token for any user ID, and the database would give up a credential dump.
  • The shipped verifier fetches Keycloak's public keys from its JWKS URL once, caches them, and checks signature, issuer, audience and expiry with no per-request call.
  • The GroundedDocs adversarial suite of more than 30 cases, covering expired tokens, wrong workspaces and guessed document IDs, passes only if the system denies, abstains or leaks nothing.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint Revocation is only as fast as token expiry. At a 15-minute lifetime, a user disabled in Keycloak can keep querying for up to about 15 minutes.
  • capability Tool calls take the user from the verified principal, so neither a model's output nor a retrieved document can change whose documents an agent searches.
  • decision Before adding a login route, a team can ask the author's question, "if someone stole this API's config and database, who could they become?", and move issuance out if the answer is anyone.
  • cost Adopting the split means running a separate identity provider. The post leaves Keycloak setup and per-workspace permissions out of scope.

The author points at one call in the verifier, jwks.get_signing_key_from_jwt(token).key, and wrote: "The only key this service ever holds is Keycloak's public key. It can check a signature. It cannot produce one." [15]

The decode call next to it enforces the rest. It pins algorithms=["RS256"], with an inline comment telling the reader never to trust the token's alg header [9]. It also passes the configured audience and issuer, so a token issued for another audience or by another issuer fails [9]. Any PyJWTError becomes a 401 with the detail "invalid token" [18]. The key client is built with cache_keys=True [14].

This is good, plain engineering. The whole check is one FastAPI dependency, get_principal, and every protected route uses it [20]. The file opens with a comment that states the design: "this file verifies. Nothing in it can sign." [20]

Against the shipped build, a thief who takes the settings gets an issuer URL, an audience and a JWKS URL, all of them public [6]. User rows are keyed by Keycloak's sub claim, which the author calls "An identifier, not a credential." [7] "Steal everything inside the API and there is no one to become," the author wrote [19].

The post explains the split with a passport office and a border guard. For once the analogy matches the protocol: the guard checks the seal and the expiry and cannot print passports [21].

The signing key and the password store still exist. Keycloak runs the login page, stores passwords and signs access tokens, and the post lists Okta and Cognito as issuers for the same role [16]. I think the split is the right tradeoff when the main exposure is the service's own config and database. It holds only if the issuer runs on separate infrastructure with separate credentials. A breach that reaches both systems reaches the signing key. The adversarial suite tests the resource server's refusals [11], so it cannot show what happens when the issuer is the system that gets compromised.

What to watch

  • Whether the per-workspace permission layer, left out of the post, also takes the workspace from the verified token and not from request input.
  • How the GroundedDocs verifier behaves when Keycloak rotates its signing key, given that its key client caches fetched keys.
  • Whether the adversarial suite gains cases aimed at the issuer side, such as tokens from a second issuer that share the same audience.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories