Authentication
The three authentication modes, how principals are derived, and what each mode is safe for.
Authentication decides one thing that matters enormously: which principal a request runs as. The principal is what the storage namespace derives from, so it is the boundary between one user's files and another's. Get it wrong and every caller shares one namespace, or worse, executes code with no identity at all.
The three modes
| Mode | Set by | Principal comes from | Safe for |
|---|---|---|---|
| Local | LOCAL_MODE=true | A fixed built-in principal | Loopback development only |
| None | CODEAPI_AUTH_PROVIDER=none | User-Id header → body user_id → anonymous | Behind a gateway that authenticates for you |
| JWT | CODEAPI_AUTH_PROVIDER=librechat-jwt | Verified EdDSA token claims | Production |
LOCAL_MODE=true wins over everything. It bypasses authentication before
the provider is ever consulted. Configuring JWTs while leaving local mode on
gets you unauthenticated access with extra configuration.
Local mode
LOCAL_MODE=trueThe default in every local Compose file. Every request is stamped with a fixed local principal: no header, no token, no check.
This means anyone who can reach port 3112 can execute arbitrary code. Bind it to loopback and nothing else.
Provider: none
LOCAL_MODE=false
CODEAPI_AUTH_PROVIDER=none
CODEAPI_ALLOW_AUTH_PROVIDER_NONE=trueThe service takes the user id from, in order:
- the
User-Idrequest header, - the request body's
user_id, - the literal
anonymous.
The second flag is a deliberate speed bump: the service refuses to start in
this mode unless CODEAPI_ALLOW_AUTH_PROVIDER_NONE=true is also set, so you
cannot arrive here by typo.
The caller supplies their own identity here. Any client can claim to be any
user and read that user's files. Only use this behind something that
authenticates and rewrites User-Id itself, on a network the public cannot
reach.
JWT (EdDSA) mode
The production path. LibreChat signs a short-lived Ed25519 JWT; this service verifies it. The private key never leaves the signer.
LOCAL_MODE=false
CODEAPI_AUTH_PROVIDER=librechat-jwt
CODEAPI_JWT_ISSUER=librechat
CODEAPI_JWT_AUDIENCE=codeapi
CODEAPI_JWT_ALLOWED_ALGS=EdDSA
CODEAPI_JWT_KID=lc-codeapi-1
CODEAPI_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"Required claims
The signer populates these; the verifier checks them:
| Claim | Checked against |
|---|---|
iss | CODEAPI_JWT_ISSUER |
aud | CODEAPI_JWT_AUDIENCE |
sub | Becomes the principal's user id |
jti | Token id |
iat / nbf / exp | Validity window, with skew tolerance |
principal_source | Where the identity originated |
auth_context_hash | Binds the token to its auth context |
The token's kid header selects the verifier key. An unknown kid is rejected
without further checks.
Lifetime
| Variable | Default | Max |
|---|---|---|
CODEAPI_JWT_MAX_TTL_SECONDS | 300 | 300 |
CODEAPI_JWT_CLOCK_SKEW_SECONDS | 30 | 30 |
Both are capped by the service. Tokens are meant to be minted per request or near enough; a five-minute ceiling is the most you get.
Supplying verifier keys
Three mutually compatible options:
CODEAPI_JWT_KID=lc-codeapi-1
CODEAPI_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"PEM or base64-encoded DER. Escaped \n is accepted, which is what makes this
survivable in .env files and Kubernetes secrets.
Key rotation
Because the kid selects the key, rotation is additive:
Add the new public key to the verifier
Put it in the JWKS document or the key directory alongside the current one. Both are now accepted.
Switch the signer
Point LibreChat at the new private key and its kid. Tokens signed with either
key still verify.
Wait out the old tokens
At most CODEAPI_JWT_MAX_TTL_SECONDS: five minutes by default.
Remove the old public key
Nothing in flight is signed with it any more.
Internal service authentication
Separate from caller authentication, the components authenticate to each other with a shared token:
CODEAPI_INTERNAL_SERVICE_TOKEN=<strong random value>When this is unset, file-object routes and Tool Call Server session-management routes stay unauthenticated for backwards compatibility. In any deployment where the internal network is not fully trusted, this is a real hole. Set it.
The local Compose files inline localdev-internal-service-token, which is
public knowledge.
Tenant isolation
CODEAPI_TENANT_ISOLATION_STRICT=trueThe storage namespace can include a tenant dimension. Single-tenant deployments
may not populate a tenant id at all, in which case legacy is used. Setting
this to true rejects requests whose auth context carries no tenant id, rather
than silently pooling them under legacy.
Turn it on for any multi-tenant deployment.
Verifying your setup
The check that matters is that an unauthenticated request fails:
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://your-host/v1/exec \
-H 'Content-Type: application/json' \
-d '{"lang":"py","code":"print(1)"}'401 is correct. 200 means you are in local mode or none mode, whatever
your JWT configuration says.