Skip to content

Authentication and bearer tokens

Every call a Chronicle client makes to the Kernel over gRPC needs a bearer token attached to it. This page describes the exchange a new client has to implement to get one, keep it fresh, and attach it correctly — reverse-engineered from the .NET client’s reference implementation, since that’s where the behavior is defined today.

Which mode a connection uses is decided entirely by what’s present in the connection string — there’s no separate flag:

ModeConnection string carriesWhat happens
Client credentialsusername:password@host (or nothing at all)Exchanged for a bearer token via OAuth’s client_credentials grant
API key?apiKey=... query parameterSent as-is (no exchange)
None?auth=none query parameterNo credentials presented at all — only works against a server with authentication turned off

Supplying both client credentials and an API key in the same connection string is an error (AmbiguousAuthenticationMode); supplying neither and not asking for auth=none is also an error (MissingAuthentication) — except that an empty connection string is treated as client credentials rather than an error, because of the development default below. See Connection string elements for the full grammar.

Client credentials really means OAuth client-credentials

Section titled “Client credentials really means OAuth client-credentials”

It’s easy to assume username:password@host is HTTP Basic auth. It isn’t. The username and password in the connection string map directly onto an OAuth client_credentials grant: username becomes client_id, password becomes client_secret, and the client exchanges them for an access token by calling the Kernel’s own token endpoint before making any other RPC:

POST https://{host}:{port}/connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id={username}&client_secret={password}

The response is parsed for access_token and expires_in (a missing expires_in defaults to 3600 seconds). The client secret is never sent as a Basic-auth header, and it’s never sent on every call — only once, to get a token, which is what then goes on every call.

A connection string with no credentials at all still resolves to client-credentials mode — it substitutes two well-known development values rather than connecting anonymously:

client_id = chronicle-dev-client
client_secret = chronicle-dev-secret

chronicle://localhost:35000 and chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35000 are exactly equivalent. This is what makes local development work with no setup, and it’s also why those two values are not a secret worth protecting — they’re baked into every Chronicle client and meaningless outside a server explicitly configured to accept them.

Once a token exists, it goes on every outgoing gRPC call as a metadata header:

authorization: Bearer {access_token}

A gRPC client interceptor is the natural place to do this — it needs to run for unary calls and every streaming variant (client-streaming, server-streaming, duplex), and it needs one more piece of behavior beyond just attaching the header: if a call comes back Unauthenticated, force a token refresh and retry the call exactly once before giving up. That single retry is what makes a token that expires mid-session invisible to the caller instead of surfacing as a hard failure.

A well-behaved client refreshes proactively rather than waiting to be rejected:

  • Track when the token was issued and how long it’s valid for (expires_in).
  • Treat it as needing renewal a margin before actual expiry — a 60-second margin is a reasonable default — so a call that starts just before expiry doesn’t race the clock.
  • If a refresh attempt fails (the token endpoint is briefly unreachable, say), keep serving the still-technically-valid cached token rather than failing every call until the endpoint recovers. Throttle repeated failed-refresh attempts (a few seconds between retries) so a down token endpoint doesn’t turn into a tight retry loop on every call.
  • Treat an explicit refresh request (triggered by the 401-retry above) differently from routine proactive renewal: it should bypass the failure throttle, since it’s evidence-driven rather than a guess that the token might be stale.

Short-lived processes — a CLI invoked once per command, a script run from cron — pay the full token-exchange cost on every single invocation if the token only ever lives in memory. A client worth using from tooling like that benefits from an optional decorator that persists the token to a local file (client ID, token, and expiry — nothing else) and reuses it across process invocations until it’s genuinely close to expiring, deleting the cache file and fetching fresh on an explicit refresh. Treat a missing or corrupt cache file as “no token” and fetch normally rather than failing — a cache is an optimization, never a dependency.

  • Determine the auth mode from the connection string (client credentials / API key / none), with the same “empty means development defaults” and “both present is an error” rules.
  • For client credentials, perform the OAuth client_credentials exchange against /connect/token on the configured host.
  • Attach authorization: Bearer {token} to every call, across every streaming shape your gRPC library exposes.
  • Cache the token, refresh it proactively ahead of expiry, and retry once on a 401.
  • Never log a raw connection string or a raw token — see Connection string elements for why and how the .NET client avoids it.

Next: Clustering and the connection lifecycle covers what happens before and after that first authenticated call — picking a server and staying connected to it.