On this page
- What makes an API key different from a setting?
- Use Keychain for small secrets
- Separate credential identity from connection metadata
- Choose accessibility intentionally
- Background access changes the tradeoff
- Biometric protection is not automatically better UX
- Do not store the key in UserDefaults
- Avoid copying secrets into memory unnecessarily
- Do not place the key in the URL
- Redact before logging
- Custom headers need classification
- Avoid secrets in crash reports
- Exclude credentials from analytics
- Backups should not contain API keys
- Be careful with synchronizable Keychain items
- Keychain access groups expand who can read a secret
- Model credential lifecycle explicitly
- Rotation should be easy
- Deletion must remove the secret itself
- “Delete all data” needs a credential pass
- Do not show the raw key by default
- Avoid secret validation that leaks too much
- A safe request-building boundary
- Test the secret boundary
- Where BYOKchat fits
- Further reading
An AI API key should be treated as a credential, not as an ordinary app setting.
That sounds obvious, yet many mobile apps still make mistakes such as:
UserDefaults.standard.set(apiKey, forKey: "apiKey")
or:
settings.json
{
"apiKey": "sk-..."
}
The correct model is:
Small long-lived secrets belong in the platform secret store, with explicit access, deletion, sharing, and backup behavior.
On Apple platforms, that usually means Keychain Services.
What makes an API key different from a setting?
A setting such as:
theme = midnight
has low security value.
An API key can authorize paid requests, expose account capabilities, or access provider resources.
If leaked, it can often be used from another device immediately.
That makes it:
- compact;
- portable;
- high-value;
- revocable;
- security-sensitive throughout its lifetime.
Use Keychain for small secrets
Apple’s Keychain Services is designed for small sensitive values such as passwords, tokens, keys, and certificates.
A simple storage abstraction can look like:
struct ProviderCredential {
let connectionID: UUID
let secret: Data
}
The connection record in your app database stores metadata:
connection id
provider type
display name
base URL
model preferences
credential reference
The Keychain stores the secret itself.
Do not duplicate the secret into the database “for convenience.”
Separate credential identity from connection metadata
A robust design looks like:
The app database can be backed up and migrated without carrying raw credentials.
This also makes explicit deletion easier.
Choose accessibility intentionally
Keychain items can have access conditions tied to device state.
The exact choice depends on the app’s behavior.
Questions include:
- Must the key be readable while the device is locked?
- Does background generation need it?
- Should the item require a device passcode?
- Should biometric/user presence be required?
- Should the secret migrate to another device?
Do not blindly copy one accessibility constant from a blog post.
Choose it from the product’s actual lifecycle.
Background access changes the tradeoff
Suppose the app supports a long-running request that can continue while the device is locked.
If the credential is unavailable while locked, the app may not be able to start a new authenticated request in that state.
That does not mean the correct solution is to weaken all Keychain protection.
Instead, decide explicitly:
foreground-only credential access
or
background-capable credential access
and document the security tradeoff.
Biometric protection is not automatically better UX
You can protect a Keychain item with user-presence requirements such as Face ID or Touch ID.
That is useful for some high-value secrets.
But asking for biometric authentication before every streamed request may make an AI chat app unpleasant to use.
A better question is:
What action should require re-authentication?
Examples:
revealing the raw API key
exporting credentials
changing a sensitive connection
performing a destructive admin action
Credential retrieval for normal foreground requests may use a different policy.
Do not store the key in UserDefaults
UserDefaults is for preferences, not long-lived secrets.
Bad:
UserDefaults.standard.set(apiKey, forKey: "provider.apiKey")
Better:
UserDefaults / database -> non-secret provider settings
Keychain -> credential
The same rule applies to:
- plist files;
- JSON config;
- SQLite text columns;
- Core Data string fields;
- plain files in Documents/Application Support.
Avoid copying secrets into memory unnecessarily
A mobile process must eventually hold the credential in memory to construct an authenticated request.
The goal is not “the key is never in RAM.”
The goal is to reduce unnecessary copies and lifetime.
Prefer:
retrieve secret
construct request
release temporary value
Avoid:
singleton.currentAPIKey
global debug state
UI model containing raw key forever
analytics context with credential
Do not place the key in the URL
Use the provider’s required authentication header or supported credential mechanism.
Bad:
https://api.example.com/v1/chat?api_key=SECRET
Better:
Authorization: Bearer SECRET
or the provider-specific header.
URLs are copied into logs, diagnostics, browser/history systems, proxy records, and analytics much more easily than properly handled secret headers.
See Why API Keys Should Never Be Put in URLs.
Redact before logging
The logger should never rely on every caller remembering to hide secrets.
Centralize redaction.
Example:
func sanitized(headers: [String: String]) -> [String: String] {
headers.mapValues { _ in "[REDACTED]" }
}
In practice, preserve safe headers and redact known credential-bearing ones:
Authorization
x-api-key
api-key
provider-specific auth headers
custom protected headers
See How to Redact Secrets From AI App Logs.
Custom headers need classification
A custom OpenAI-compatible connection may allow arbitrary headers.
Not every header is public metadata.
For example:
X-Organization: public-ish identifier
X-API-Key: secret
Authorization: secret
X-Internal-Token: secret
If users can mark headers as protected, keep protected values in secret storage too.
Do not serialize them into the ordinary provider configuration object.
Avoid secrets in crash reports
Crash systems often capture:
- breadcrumbs;
- request descriptions;
- custom metadata;
- logs;
- error strings.
If an error contains:
401 request failed: Authorization=Bearer sk-...
and you send it to a crash service, the key has left the device.
Sanitize before constructing diagnostic strings.
Exclude credentials from analytics
Telemetry should never contain:
API keys
OAuth access tokens
refresh tokens
protected custom headers
raw authorization headers
A useful analytics record can identify a connection using an app-local opaque ID without exposing the credential.
{
"connection_id": "local-opaque-id",
"provider_kind": "anthropic",
"result": "rate_limited"
}
Backups should not contain API keys
A chat backup should preserve user content and configuration while excluding credentials.
For example:
{
"provider": {
"name": "Work Anthropic",
"kind": "anthropic",
"credential": null
}
}
After restore, the app can show:
Credential required
instead of silently importing a secret from a portable archive.
See Backup and Restore Security for Local AI Chats.
Be careful with synchronizable Keychain items
Some Keychain items can synchronize across devices.
That may be convenient, but it changes the trust and product model.
Ask:
- Does the user expect this API key on another device?
- Is synchronization allowed by provider/account policy?
- Does the app promise device-local credential storage?
- How does deletion propagate?
Do not enable synchronization accidentally.
Keychain access groups expand who can read a secret
Shared Keychain access groups can let multiple apps from the same development team access a credential.
That can be useful for an app + extension architecture.
It also expands the trust boundary.
If BYOKchat and another product do not need to share credentials, do not put them in the same access group merely because it is possible.
Least privilege applies to app groups too.
Model credential lifecycle explicitly
A credential has states:
not configured
configured
valid
invalid
revoked
replaced
deleted
The app should not infer too much from one failed request.
A 401 can mean:
- key revoked;
- wrong key;
- wrong provider account;
- wrong endpoint;
- malformed auth header;
- provider-specific auth policy.
Keep storage state separate from request health.
Rotation should be easy
A good UI lets the user replace a key without rebuilding the whole connection.
A safe sequence:
1. user enters replacement
2. app validates format only if reliable
3. app stores replacement atomically
4. future requests use new credential
5. old credential reference is deleted
Do not log the old/new values during migration.
Deletion must remove the secret itself
Deleting a provider connection should not only remove the database row.
It should also delete the associated Keychain item.
A reliable implementation can model this as:
Delete connection:
- cancel active work
- delete connection record
- delete credential item
- remove cached auth state
- remove protected headers
If deletion partially fails, retry or surface a recoverable state.
“Delete all data” needs a credential pass
An app-level reset should include secret storage explicitly.
It is easy to wipe:
SQLite
files
preferences
and forget Keychain items because they live elsewhere.
Test the reset path by reinstall/relaunch scenarios relevant to the platform.
Do not show the raw key by default
After saving, the UI usually needs only:
Configured
or a masked hint:
••••••••abcd
If the product supports revealing/copying the credential, treat that as a sensitive action.
Avoid secret validation that leaks too much
Some apps send a “test request” as soon as the user types a key.
That is fine only when the destination is exactly the configured provider and the user understands the action.
For custom endpoints, do not send credentials to an unvalidated host before the user confirms the connection details.
A safe request-building boundary
Keep raw credentials close to the network boundary.
The UI does not need to carry the secret through the whole app architecture.
Test the secret boundary
Useful automated tests include:
connection JSON contains no credential
backup archive contains no credential
analytics payload rejects credential fields
logger redacts Authorization and x-api-key
connection deletion removes Keychain item
replace-key flow leaves no old Keychain item
delete-all removes all app-owned credentials
custom protected headers use secret storage
You can also add a test fixture with an obvious fake secret:
TEST_SECRET_MUST_NEVER_LEAK_12345
and scan generated logs/backups for it.
Where BYOKchat fits
A BYOK client needs credentials to remain user-owned without becoming casually exposed inside app state.
The clean boundary is:
provider configuration -> local app data
provider credential -> Keychain
Backups, telemetry, conversation storage, and diagnostics should work without ever needing the raw key.
That makes credential handling easier to audit and easier to delete correctly.