Data handling
This page lists what a self-hosted instance stores, where, how it is protected and when the server deletes it. It also lists the outside services the server calls. It describes the server and the policyarc-server Helm chart. It does not describe the hosted service.
Where data lives
| Place | What it holds |
|---|---|
| The PostgreSQL database | Every record the server keeps. You run this database, see Deployment model. There is one exception. When PBAC_OIDC_JWKS_PATH is set, the server reads its signing key from that file, or generates one and writes the private key to it unencrypted. The setting is empty by default and the chart does not set it. |
| Each server pod's memory | Caches it rebuilds after a restart, such as the policy bundle, keys fetched from identity providers, the decrypted signing keys, Google service account credentials built from a connector secret, rate limit counts and open MCP streams |
| The OPA pod | A copy of the policy bundle, which holds the policy rules and the control data rows. OPA keeps the last bundle it activated in /tmp/opa, an emptyDir volume. |
| Standard output of each container | The server's log lines and OPA's decision log. Your log collector decides where they go and how long they stay. |
The chart has no other store. It runs no cache server, no object store and no mail server.
What the database holds
Each row below names the tables, how the value is stored and what deletes it. "Encrypted" means encrypted with the credential key, described in the next section. We listed the tables of a test instance's database on version 0.65.0. The table covers each of them except flyway_schema_history, which records the migrations.
| Data | Tables | How it is stored | Deleted by |
|---|---|---|---|
| People who signed in | subject, subject_userinfo | Plain. subject_userinfo holds the name, email, preferred username and whether the email is verified. | No scheduled job. DELETE /admin/api/subjects/{id} removes the subject row and clears resource.owner_subject_id where it names the subject. Its subject_userinfo row, its tokens and its audit rows stay. Deleting the identity provider deletes its subject_userinfo rows. |
| Browser sessions | spring_session, spring_session_attributes | Plain. spring_session_attributes.attribute_bytes holds the signed-in user's subject ID and claims. | A cleanup every 5 minutes, set by PBAC_SESSION_CLEANUP_CRON. A session times out after PBAC_SESSION_TIMEOUT, 30 minutes by default. |
| Access tokens | access_token | The token as issued, not hashed. Each row also holds the policy input and output, which include the client, the grant, the caller's IP address and user agent, and the user's claims for a user's token. | A cleanup once the token has expired, every pbac.cleanup.interval-seconds, 3600 by default. Also revocation, the use of a single-use token, deleting the client, and a start with PBAC_CREDENTIAL_KEY_DISCARD_PER_USER=true. |
| Refresh tokens | refresh_token | The token as issued, with the policy input and the user's claims | The same cleanup once expired, rotation when it is used, revocation, deleting the client or the identity provider, and a start with PBAC_CREDENTIAL_KEY_DISCARD_PER_USER=true |
| Sign-in in progress | authorization_request, authorization_code, idp_flow | Plain. authorization_code.code is the code as issued and authorization_code.claims holds the user's claims. idp_flow holds the identity provider's ID token and the claims read from it. | The cleanup deletes expired requests and codes, and a code is deleted when it is redeemed. An idp_flow row goes with its request. A request expires after 10 minutes and a code after 5, by default. Deleting the client deletes its requests and codes. |
| Approval requests | backchannel_auth_request | Plain, including login_hint, binding_message, the policy input in pdp_input, and the approver's ID token | The cleanup once expired, and deleting the client or the approval identity provider |
| Replay defence | used_jti, dpop_nonce | Plain | The cleanup once expired |
| Clients | client | The client secret as a BCrypt hash, and the previous secret's hash during a rotation. software_statement holds the claims of the statement the client registered with, and issued_software_statement_jwt the statement the server issued to it. | DELETE /admin/api/clients/{clientId}, which also deletes the client's tokens, codes, authorization and approval requests, UMA permission tickets, UMA resources and UMA delegations |
| Identity providers | identity_provider | The client secret is encrypted | DELETE /admin/api/identity-providers/{idpId}, which also deletes the provider's subject_userinfo, refresh_token, authorization_code, backchannel_auth_request and upstream_identity_token rows. It keeps access tokens and subjects, and clears their link to the provider. |
| Admin keys | admin_api_key | The key is encrypted, plus a SHA-256 lookup hash mixed with PBAC_ADMIN_PEPPER | No scheduled job. A revoked key keeps its row. |
| Signing keys | oidc_signing_key | The private key is encrypted | A sweep every pbac.oidc.key-sweep-seconds, 600 by default, deletes a retired key once its publication window has ended |
| Connector and resource credentials | connector_secret, managed_resource | connector_secret.value_enc and managed_resource.rs_client_secret_enc are encrypted. managed_resource.upstream_auth is plain, and a static header in it can hold a literal credential. | No scheduled job |
| Each user's upstream credentials | upstream_identity_token, upstream_exchanged_token, user_upstream_secret | Encrypted. The first holds a user's identity provider tokens, the second tokens exchanged from them, and the third a credential the user typed in. | No scheduled job. A start with PBAC_CREDENTIAL_KEY_DISCARD_PER_USER=true deletes all three, and deleting the identity provider deletes its upstream_identity_token rows. |
| Policy | policy_rule, policy_data | Plain | No scheduled job |
| Decision audit | audit_log | Plain. A row holds a summary of the request in request_summary, the policy input, the decision, the client, the subject, the IP address and the user agent. | Never. See Audit retention. |
| Bundle audit | bundle_audit_log | Plain. A row holds the bundle's ETag and the previous one, the rule paths, the data keys, the bundle size, what triggered the build and who triggered it. | Never |
| MCP sessions | mcp_session | Plain | An hourly sweep, once expired |
| UMA permission tickets | uma_permission_ticket | Plain, the ticket as issued | Nothing deletes a ticket except deleting its client |
| UMA resources and delegations | uma_resource, uma_delegation, uma_delegation_scope | Plain. uma_resource.owner_subject_id and the grantor and grantee subject IDs in uma_delegation identify people. | Deleting the client |
| Configuration | resource_server, resource_type, resource, scope, connector, connector_artifact, and the join tables resource_scope, resource_server_type and resource_type_scope | Plain | No scheduled job |
To keep a credential out of managed_resource.upstream_auth, store it as a connector secret and put a reference such as {{secret.jira_token}} in the static header value. The server replaces the reference with the decrypted secret on each call.
A token is stored as issued, so anyone who can read the access_token or refresh_token table can use a live token. Limit who can read the database the way you limit who can call the Admin API. We issued a token on a test instance and found it in access_token.token unchanged.
We deleted a subject that had signed in, through the Admin API on a test instance. The call returned HTTP 204. Its subject_userinfo row, with its name and email, its access token and its two audit rows were still there.
How secrets are encrypted
PBAC_CREDENTIAL_KEY encrypts each column the table above marks as encrypted. The server uses AES-GCM through Spring Security's Encryptors.delux, which derives the AES key from the credential key. The server writes v1: before each value it encrypts. The prefix is the same for every key, so it does not tell you which key wrote a value, and the server also reads values written without it. No API returns a decrypted connector secret. GET /admin/api/admin-keys/{id}/value returns a decrypted admin key, and each call writes an audit row. Rotate keys and secrets says what happens when the key changes. Changing it by hand stops the server from starting.
Client secrets are not encrypted. They are BCrypt hashes, and the server shows a secret only once.
You can check a stored secret on your own instance. Set the server URL, an admin key, and a connection string for the database the server uses. The block needs psql, curl, od and base64.
export POLICYARC_URL="https://your-environment.example"
export POLICYARC_ADMIN_KEY="paste the Admin API key"
export POLICYARC_DATABASE_URL="postgresql://pbac:password@your-database.example:5432/pbac"
This block stores a secret through the Admin API, reads the stored column with psql, deletes the secret, and then checks the stored value. It deletes the secret before any check, so a failed check leaves nothing behind.
name="at_rest_check_$(date +%s)"
plain="visible-if-not-encrypted"
curl -fsS -o /dev/null -X POST "$POLICYARC_URL/admin/api/secrets" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" -H 'Content-Type: application/json' \
-d "{\"name\": \"$name\", \"value\": \"$plain\"}"
stored=$(psql "$POLICYARC_DATABASE_URL" -Atc "select value_enc from connector_secret where name = '$name'") || stored=""
curl -fsS -o /dev/null -X DELETE "$POLICYARC_URL/admin/api/secrets/$name" -H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY"
echo "stored value is ${#stored} characters and starts with ${stored:0:12}"
case "$stored" in v1:*) ;; *) echo "Expected a value that starts with v1:, got ${stored:0:12}" >&2; false ;; esac
# v1:, then hex of a 16-byte IV, the ciphertext and a 16-byte GCM tag
min=$((3 + 2 * (16 + ${#plain} + 16)))
[ "${#stored}" -ge "$min" ] || { echo "Expected at least $min characters, got ${#stored}" >&2; false; }
hex=$(printf %s "$plain" | od -An -tx1 | tr -d ' \n')
! grep -qF "$plain" <<<"$stored" || { echo 'The stored value holds the plaintext' >&2; false; }
! grep -qiF "$hex" <<<"$stored" || { echo 'The stored value holds the plaintext in hex' >&2; false; }
! grep -qF "$(printf %s "$plain" | base64)" <<<"$stored" || { echo 'The stored value holds the plaintext in base64' >&2; false; }
On our test instance the stored value was 115 characters and started with v1:. Creating and deleting the secret each write an audit row.
The block checks one of the nine encrypted columns, connector_secret.value_enc. It shows that the stored value is long enough to be the ciphertext and does not hold the plaintext as text, hex or base64. It does not show which key encrypted the value, or that the other eight columns are encrypted.
Audit retention
The server never deletes audit rows and has no retention setting. Migration V73 adds triggers that reject UPDATE, DELETE and TRUNCATE on audit_log and bundle_audit_log. A database owner can disable them, and the server's own database role owns those tables. Audit log shows how to purge old rows.
What leaves the instance
The server and OPA call these outside services. The chart's network policy lets the server reach any address on TCP 80 and 443, and OPA any address on TCP 443.
| Destination | When | What is sent |
|---|---|---|
| Identity providers | A user signs in, the server reads the user's profile from the userinfo or introspection endpoint, a token is refreshed or exchanged for the managed gateway, an administrator runs discovery, or the server fetches the provider's discovery document and keys | OAuth and OpenID Connect requests with the provider's client ID and secret, and the user's token for userinfo and introspection |
| Client key sets and client ID metadata documents | A client registers or authenticates with a key set URL or a metadata document URL | A fetch of the URL |
| Software statement issuers and the SPIFFE trust bundle URL | A statement or a JWT-SVID is checked | A fetch of the issuer's keys or the bundle |
| Upstream APIs of managed resources | A client calls through the managed gateway and policy allows it | The call, with the upstream credential the resource is set up to use |
| Upstream MCP servers | An administrator asks the server to discover a managed resource's tools | An MCP tool listing request |
| Google's token endpoint | The gateway calls an upstream that uses a Google service account | A signed request for an access token |
Google APIs, https://www.googleapis.com unless a policy sets api_base | A gateway obligation reads a file's labels or applies a label | Calls with the upstream credential of the request |
| The approval identity provider | A CIBA request asks a person to approve | A signed notification |
| Atlassian | An administrator looks up a site's cloud ID with GET /admin/api/connectors/atlassian-cloudid | A fetch of https://<site>.atlassian.net/_edge/tenant_info |
| Your OTLP collector | Only when you turn on telemetry export | Traces, metrics and logs. Telemetry export lists the fields. |
| Loki | Only when the server runs with the loki Spring profile, which the chart does not set | The server's log lines |
| OPA's version check | OPA starts. The chart runs OPA without --disable-telemetry. | A request to the OPA project's telemetry service. Our local OPA logged OPA is out of date with the latest version, which is the reply. The chart has no value that turns this off. |
The server checks four kinds of fetch before it makes them. They are a client's key set, a client ID metadata document, a software statement issuer's discovery document, and an identity provider's discovery document when an administrator runs discovery from the Admin API. For these it requires HTTPS, refuses private, loopback and link-local addresses, and does not follow redirects. PBAC_SAFE_HTTP_ALLOW_INSECURE_LOCALHOST relaxes this for listed development hosts and is off by default. The server does not check the identity provider's discovery document and key set that it fetches during sign-in, or the SPIFFE trust bundle. It fetches those from the URLs an administrator configured. Managed resource upstreams get the address check, but may use http. The trust center gives the detail.
Inside the instance, the server sends OPA the full input of each decision, and OPA fetches the policy bundle from the server.
Logs
The chart sets no Spring profile, so the server writes JSON log lines to standard output. Monitor an instance shows a line. We read the server's INFO and WARN log statements and found none that writes a token, a secret or a claim value. Some WARN lines name a subject ID. A failed call to the token introspection endpoint from the gateway logs the response body, and an error from an outside call can include the text of the upstream response.
OPA writes a decision log line for each decision to its own standard output, because the chart's OPA configuration sets decision_logs to console. That line holds the full decision input, including the client, the IP address and the user's claims. Treat the OPA log as you treat the audit table.
Send feedback about this page