Repository navigation
"Secret" session state scope #5112
Description
Context
I'm currently working with security with ADK. I'm using OAuth flow, with client_id and client_secret or external_access_token_key (for Gemini Enterprise integration)
oauth_credentials_config = BigQueryCredentialsConfig(client_id=client_id, client_secret=client_secret)
oauth_bigquery_toolset = BigQueryToolset(
credentials_config=oauth_credentials_config,
tool_filter=["execute_sql"])
AUTH_ID=os.getenv("AUTH_ID","auth-guillaume")
external_credentials_config = BigQueryCredentialsConfig(external_access_token_key=AUTH_ID)
external_bigquery_toolset = BigQueryToolset(
credentials_config=external_credentials_config,
tool_filter=["execute_sql"])
I also use the VertexAISessionService for session persistence, and the BigQuery Agent Analytics (BQAA) plugin for tracking and observability
Issue
All the token shared are expired or redacted
When authentication is completed, the user credentials are stored in the session state. And persisted.
- Either in Agent Engine Session
$ curl -H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" \
"https://europe-west1-aiplatform.googleapis.com/v1/projects/c4-blaquiere-sbx/locations/europe-west1/reasoningEngines/6454467506579963904/sessions/2847107844556193792"
{
"name": "projects/513069150666/locations/europe-west1/reasoningEngines/6454467506579963904/sessions/2847107844556193792",
"createTime": "2026-03-30T08:43:39.228641Z",
"updateTime": "2026-03-30T08:47:06.051848Z",
"sessionState": {
"thread_id": "Lpge4IwU8n3",
"bigquery_token_cache": "{\"token\": \"ya29.a0Aa7MYipRrj1AMKYlFPuRB4b_KZJhBDPmUD4MILEO5SYUzCDArrpzbrzyKO3VGrVwsfdTuwZuIuwm5BRgWdMgvnW9Z8VjlJ2K27075dSBezRhe9ChF-NyCC6kjk5ufzXcB1MCP44DBgFCFWPEGaYzw86Vm5ZOLYrAFJ2dBeBuTLKyiO1_M_c8QQZqhzQLuu-nMC6dJOYaCgYKAecSARESFQHGX2Miu5exN9Uek1R2IK25F3OK5g0206\", \"refresh_token\": \"1//031DBLABLABLABLABLABLA-RKQYHFIC-HOVuH2jU7u7WS7vATuNnxY0lhu7gQ\", \"token_uri\": \"[https://oauth2.googleapis.com/token\](https://oauth2.googleapis.com/token%5C)", \"client_id\": \"[513069150666-fdb9nhhvueaordq5ol3pn5m1oetk8t91.apps.googleusercontent.com](http://513069150666-fdb9nhhvueaordq5ol3pn5m1oetk8t91.apps.googleusercontent.com/)\", \"client_secret\": \"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\", \"scopes\": [\"[https://www.googleapis.com/auth/cloud-platform\](https://www.googleapis.com/auth/cloud-platform%5C)", \"[https://www.googleapis.com/auth/userinfo.email\](https://www.googleapis.com/auth/userinfo.email%5C)", \"[https://www.googleapis.com/auth/userinfo.profile\](https://www.googleapis.com/auth/userinfo.profile%5C)"], \"universe_domain\": \"[googleapis.com](http://googleapis.com/)\", \"account\": \"\"}"
},
"userId": "users-112833807918985604918",
"expireTime": "2027-03-30T08:43:39.190195Z"
}
- Or in BigQuery with the BQAA
client_idandclient_secret
- with
external_access_token_key
Feature request
The problem comes from the persistance of ALL the states without any distinction of sensitivity. That's why, I propose to add an additional scope to the session state (in addition to the user, session, temp, and app).
This scope can be named "secret", "sensitive" or whatever. But, by contract, this scope state MUST never be persisted, at least by all the component provided by ADK out of the box: session service, memory service, plugins (like BQAA, maybe others). Or they can be persisted but with the value <redacted>, just to mention the presence of them to the user/developer, but without sharing the true content of it.
This "secret" scope would be useful for users also. It would be possible to store in the state sensitive data, like a credit card number, to be able to use this state in multiple agent, sub agent and turns, but never the value will be persisted
Side impact
In case of distributed system, the multi-turn conversation with an agent would require multiple time the sensitive information (credential or other business value like credit card), that could degrade the UX.
However, a sticky session mechanism could mitigate this downside.
Impacts on ADK
This feature would impact several parts:
- Token storage in the state, with a scope change
- New scope management, in the scope delta and other functions
- Changes in the sessions services implementation (VertexAI, database,...)
- Changes in the BQAA plugin (and maybe others)
- Update in the documentation. Clearly mention the contract to not persist the secret session state scope; without being able to prevent bad developer to implement bad custom code.
Willingness to contribute
I would like to contribute to this feature, after a first round of design and naming validation.
Activity
caohy1988 commentedon Apr 2, 2026
Validation against latest
main(9fec5030)Validated the claims in this issue against the current codebase. The core security concern is confirmed, with some additional findings and one design correction.
1. Confirmed: sensitive credentials are persisted in session state
_google_credentials.py:207writescreds.to_json()(containingtoken,refresh_token,client_id,client_secret) intotool_context.state["bigquery_token_cache"]— an unprefixed key.SessionStateCredentialService(session_state_credential_service.py:81) writes fullAuthCredentialobjects (withOAuth2Authfields:access_token,refresh_token,client_secret,id_token) underauth_config.credential_key— also unprefixed.extract_state_delta()(_session_util.py:48) routes all unprefixed keys into the persisted"session"bucket.This means all tokens and secrets are stored in plaintext in whatever session backend is configured (Vertex AI sessions, Spanner, SQLite, etc.). The issue author's curl output is an accurate reproduction.
2. Confirmed:
temp:only protects intermediate auth stateauth_handler.py:58correctly uses"temp:" + credential_keyduring the OAuth exchange flow.SessionStateCredentialServicere-stores the final exchanged credentials without thetemp:prefix (session_state_credential_service.py:81), so they are persisted.3. Confirmed: BQAA redaction is incomplete
The BQAA plugin redacts known sensitive dict keys (
client_secret,access_token,refresh_token,id_token,api_key,password) via_recursive_smart_truncate()atbigquery_agent_analytics_plugin.py:269. However:bigquery_token_cacheis stored as a JSON-encoded string, not a nested dict. The redaction logic walks dict keys but does not parse opaque JSON strings, so secrets inside the blob pass through unredacted.external_access_token_key(_google_credentials.py:78) is user-defined. If the token is stored under a custom key that doesn't match_SENSITIVE_KEYS, BQAA will log it in plaintext.4. Design correction:
secret:scope is more than a prefix changeThe proposed
secret:scope is directionally right but architecturally more complex than thetemp:pattern it mirrors:temp:works because it only needs to survive within a single invocation — it's applied to the in-memorysession.stateobject (base_session_service.py:118) and trimmed before persistence (base_session_service.py:131). The samesessionobject is reused across agents within oneRunner.run_async()call.secret:scope that survives across turns but is never persisted would require a non-persistent, cross-turn cache in every session service implementation. Currentget_session()paths rebuild state entirely from storage (database_session_service.py:537,vertex_ai_session_service.py:127), so secret-scoped values would be lost between turns unless an additional per-process cache (keyed by session identity) is introduced.Summary
temp:only covers intermediate auth, not final credentialssecret:scope needs cross-turn cache design for non-in-memory backendsThe issue is valid and well-motivated. For the implementation design, I'd suggest the RFC explicitly separates two concerns:
secret:scope: design the cross-turn, non-persistent cache model for DB/Vertex-backed session services before implementation, since it's not a straightforward extension of thetemp:pattern.caohy1988 commentedon Apr 2, 2026
RFC: Non-Persistent
secret:Session State ScopeSummary
This RFC proposes adding a new
secret:session state scope to ADK that is never persisted to storage and never logged by built-in analytics integrations (such as BQAA), but survives across turns within the same process. This addresses a confirmed security gap where OAuth credentials (tokens, client secrets, refresh tokens) are stored in plaintext in session backends and can leak into BigQuery Agent Analytics logs.Motivation
Today, ADK stores OAuth credentials in session state under unprefixed keys (e.g.,
bigquery_token_cache). Because unprefixed keys are routed to the persisted"session"bucket byextract_state_delta(), these secrets end up in plaintext in whatever session backend is configured — Vertex AI sessions, Spanner, SQLite, or in-memory storage.This creates two concrete problems:
access_token,refresh_token,client_id,client_secret) is stored in session backends. Any reader of the session store can extract valid tokens. See #5112 for a reproduction showing credentials in Vertex AI session state and BigQuery analytics.log_session_metadata=True(the default). Its_SENSITIVE_KEYSredaction catches nested dict keys likeclient_secret, but misses:bigquery_token_cacheis a JSON-encoded string, not a nested dict, so secrets inside it pass through unredacted.external_access_token_keyis user-defined and won't match_SENSITIVE_KEYSunless the key name happens to collide.The existing
temp:scope avoids persistence but only survives within a single invocation — it is trimmed fromevent.actions.state_deltabefore storage and lost when the session is re-fetched. This makes it unsuitable for credentials that need to survive across multiple conversational turns.Evidence (current
main)tools/_google_credentials.py:207creds.to_json()written tostate["bigquery_token_cache"]tools/bigquery/bigquery_credentials.py:21"bigquery_token_cache"(no prefix)auth/credential_service/session_state_credential_service.py:81AuthCredentialwritten tostate[credential_key](no prefix)sessions/_session_util.py:48"session"bucketauth/auth_handler.py:58"temp:"prefix (correct)auth/credential_service/session_state_credential_service.py:81"temp:"prefix (credentials persisted)plugins/bigquery_agent_analytics_plugin.py:269_SENSITIVE_KEYS, but not JSON string blobsProposal
Add a new
secret:state scope with these semantics:app:user:temp:secret:app:user:temp:secret:Key Design Decision: Process-Lifetime Cache
Unlike
temp:, which piggybacks on the in-memorysessionobject within a singleRunner.run_async()call,secret:state must survive across turns. This means it cannot simply be trimmed from events and forgotten — it needs a non-persistent, process-scoped cache in each session service.Current
get_session()implementations rebuild state entirely from storage:database_session_service.py:537— queries DBvertex_ai_session_service.py:127— calls Vertex AI APIAfter a round-trip through storage,
secret:-scoped values would be lost unless they are held in a separate in-process cache.Cache Design
The cache is a process-local
dictkeyed by(app_name, user_id, session_id)→dict[str, Any]for secret-scoped state.Initialization constraint:
BaseSessionServicedoes not define__init__(), and concrete session services (InMemorySessionService,DatabaseSessionService,VertexAiSessionService) define their own constructors without callingsuper().__init__(). To avoid requiring all subclasses (including third-party implementations) to change their constructors, the cache should be exposed as a lazy property onBaseSessionService:This is fully backward-compatible — no existing constructor needs to change.
The cache must be touched on all session lifecycle paths:
append_event: secret-scoped keys are applied to the cache (like_apply_temp_state) and trimmed from the event delta (like_trim_temp_delta_state).create_session: if initialstatecontainssecret:-prefixed keys, they must be seeded into the cache and preserved in the returned in-memorySession.state, but excluded from the persisted state sent to storage. Without this,create_session(state={"secret:token": "..."})would silently drop the secret keys onceextract_state_delta()filters them.get_session: after loading persisted state, merge in any cached secret state for that session identity. This is the only read path that restoressecret:state — all other read paths (list_sessions) do not.list_sessions: secret state is not merged into listed sessions.list_sessionsis a read-only summary operation; injecting process-local secret state would produce inconsistent results across processes and add overhead for a bulk operation that rarely needs credential access. Callers that need secret state should useget_sessionon individual sessions.delete_session: evict the cache entry. This must be implemented in every built-in session service:InMemorySessionService,DatabaseSessionService,VertexAiSessionService, andSqliteSessionService.Changes
Phase 1: Core scope + BQAA hardening (short-term)
Phase 1 establishes the new
secret:scope in the framework and hardens analytics redaction. It does not by itself fix the credential persistence bug — existing credential writers still use unprefixed keys until Phase 2 migrates them.1a. Add
secret:prefix toState1b. Filter
secret:keys from persistenceUpdate
extract_state_delta()to excludesecret:keys alongsidetemp::1c. Add secret-state cache and lifecycle methods in
BaseSessionServiceAdd the lazy
_secret_state_cacheproperty (see Cache Design above), then add lifecycle methods:Update
append_event()to call_apply_secret_stateand_trim_secret_delta_statebefore persistence.1d. Update session service implementations
Each concrete session service must:
_seed_secret_state_on_create()increate_session()before persisting state, and include the secret keys in the returnedSession.state._restore_secret_state()inget_session()after loading from storage._evict_secret_state()indelete_session().list_sessions()(see rationale above).Affected services:
InMemorySessionServiceDatabaseSessionServiceVertexAiSessionServiceSqliteSessionService1e. Add
secret:to validationAdd
State.SECRET_PREFIXto the valid prefixes list.1f. BQAA: redact
secret:keys and harden JSON blob handlingkey.startswith("secret:")to the redaction check alongside"temp:"._SENSITIVE_KEYS, redact the entire value.Phase 2: Migrate credential storage (follow-up)
Once the
secret:scope is available, migrate existing credential paths to use it. This is where the credential persistence bug is actually eliminated.2a. BigQuery token cache
Change:
To:
2b. SessionStateCredentialService
Both the save and load paths must be migrated. The load path (
load_credential) currently reads fromstate[credential_key]— after migration it must read fromstate[State.SECRET_PREFIX + credential_key]. If only the save path is updated, the service will write tosecret:credential_keybut look up the old unprefixed key, breaking credential retrieval silently.Save:
Load:
Backward Compatibility
bigquery_token_cachetosecret:bigquery_token_cache, existing sessions with cached credentials under the old key will require re-authentication. This should be called out in release notes.secret:— same pattern asapp:,user:,temp:.BaseSessionServicesubclasses continue to work without modification.Side Effects and Tradeoffs
secret:-scoped credentials are lost on restart, requiring re-authentication. This is the intentional security tradeoff. Sticky sessions can mitigate this. A pluggable secret storage backend is a potential future extension (see Future Direction).list_sessions()does not include secret state. This is intentional — secret state is process-local and would produce inconsistent results across processes. Callers needing secret state should useget_session().Alternatives Considered
temp:to survive across turns. Would change existingtemp:semantics and break code that depends on invocation-scoped cleanup.state.set("key", value, sensitive=True)) rather than the simpler prefix convention already established by ADK.BaseSessionServicesubclasses (including third-party) to callsuper().__init__(). Rejected in favor of a lazy property that is backward-compatible.Future Direction: Pluggable Secret Storage
This RFC deliberately scopes
secret:state as non-persistent and process-local. For stateless/distributed deployments where process restart is frequent (Cloud Run scale-to-zero, multi-replica Kubernetes), this means re-authentication on every cold start.A natural follow-up — likely as a separate RFC — would introduce an optional
SecretStateServiceinterface allowing customers to bring their own secret manager (e.g., Google Cloud Secret Manager, AWS Secrets Manager, HashiCorp Vault). That design has several open questions that are out of scope here:_restore_secret_state()and_apply_secret_state()are synchronous helpers. An external backend requires async I/O, so the helper signatures or call sites would need to change.append_event()ordelete_session()could orphan or diverge secret state.SecretManagerClient(integrations/secret_manager/secret_client.py) currently exposes onlyget_secret()— a full backend would need write and delete operations, not just a thin wrapper.These are tractable problems, but they deserve their own design discussion rather than being bolted onto this security-focused RFC.
Timeline
secret:scope + BQAA hardening + session service support — can ship as one coordinated change. Establishes the framework but does not yet fix credential persistence.secret:prefix — follow-up PR that eliminates the persistence bug. Requires release note for re-auth impact.secret:scope with custom sensitive data.Outcome
secret:prefix contract.secret:scope for any sensitive data (credit cards, API keys, PII) that needs to survive across turns without persistence.feat(sessions): add `secret:` session state scope (Phase 1)
fix(auth): migrate credential storage to secret: scope (Phase 2)
fix(auth): migrate credential storage to secret: scope (Phase 2)
fix(auth): migrate credential storage to secret: scope (Phase 2)
timof1308 commentedon Apr 6, 2026
hey, want to flag #3675 as it adds
request_statewhich is related to thisIt's a per-request ephemeral overlay on
session.statethat never gets persisted and doesn't survive across turnsMaybe this is complementary to
secret: rather than overlapping:request_stateis for short-lived tokens the client sends with each request (JWTs), whilesecretis for credentials that stick around across turns (OAuth refresh tokens). Both avoid persistence, just with different lifetimes.haiyuan-eng-google commentedon Apr 21, 2026
for
comment is: each turn can be sent to a different server, how do you achieve this without persisting?
caohy1988 commentedon Apr 21, 2026
Good question — this is the fundamental tradeoff the
secret:scope makes explicit.Short answer
You can't have all three of (non-persistent) + (survives across turns) + (works across arbitrary servers) simultaneously. The
secret:scope chooses the first two and acknowledges the third as a deployment constraint.How it works in practice
adk web)sessionAffinity: ClientIP)The re-authentication fallback is intentional — it's what happens today with
temp:-scoped auth state anyway. The difference issecret:survives across turns within the same process, so for sticky deployments or warm instances, users don't re-auth every single turn.Why not encrypt-and-persist?
It's a valid alternative, but trades one set of concerns for another:
Future Direction: Pluggable SecretStateService
The RFC's "Future Direction" section outlines the proper solution for stateless distributed deployments: an optional
SecretStateServiceinterface where customers bring their own secure backend (Secret Manager, Vault, encrypted Redis). That design has open questions (async I/O in the restore path, cross-store consistency, lifecycle management) that warrant their own RFC rather than being bolted onto the coresecret:scope.The shipped Phase 1 + Phase 2 gives a working, secure-by-default solution for the majority of ADK deployments today (single process + sticky sessions). The pluggable backend extends it to fully stateless architectures as a follow-up.
DeanChensj commentedon Oct 7, 2026
Hi @guillaumeblaquiere, thank you so much for raising this detailed security report and for your thoughtful proposal!
We have addressed the underlying credential persistence and observability exposure issues across the codebase through three complementary mechanisms (which also avoid the sticky-session limitation in distributed deployments):
KmsEncryptedCredentials):BaseGoogleCredentialsConfig(inherited byBigQueryCredentialsConfig,SpannerCredentialsConfig,GcsCredentialsConfig, etc.) now supports akms_key_nameparameter (or theGOOGLE_CREDENTIAL_KMS_KEYenvironment variable) via Encrypt sensitive credential in session state with KMS #6614. When configured, OAuth credentials cached insession.state(such asbigquery_token_cache) are automatically envelope-encrypted using Cloud KMS and Fernet DEKs before being written to session state.BigQueryAgentAnalyticsPlugin(BQAA): The BQAA plugin (fix(plugins): close BQAA fail-open privacy, GCS concurrency, and startup-loss gaps (#6356) #6360) now automatically sanitizes and redacts sensitive keys (client_secret,access_token,refresh_token,id_token,token,secret,api_key,password,private_key,authorization, as well as anytemp:*keys) to[REDACTED]across both structured mappings and serialized JSON payloads before logging to BigQuery._redact_credential_secretsingoogle.adk.auth.auth_credentialautomatically strips credential secrets fromadk_request_credentialevents,requested_auth_configs, OpenTelemetry spans, and API server responses.I am closing this issue as resolved. If you run into any edge cases or have additional requirements around credential protection, please feel free to open a new issue or PR!
guillaumeblaquiere commentedon Oct 7, 2026
@DeanChensj Thank you for the follow up and the different step to improve security and trust in agent (and having accepted my PR).