SecretStr vs str: When to Use Each
Wrapping every string field in SecretStr is as unhelpful as wrapping none of them. Mask an operational identifier and you make incidents harder to diagnose while adding nothing to security; leave one credential plain and it becomes the value that ends up in a log. The decision is per field and it is not subtle once you have a rule, which is what this page provides — along with what actually changes at the boundaries when you wrap something, extending the secret types and redaction topic.
Problem 1: wrapping the wrong things
# ANTI-PATTERN: masking identifiers makes operations harder for no security gain
class Settings(BaseSettings):
db_host: SecretStr # operators need this in a log to diagnose anything
db_user: SecretStr # an identifier, not a credential
db_password: SecretStr # correct
A masked host means the startup line reading “connecting to **********” tells nobody anything, so somebody adds a second line that unwraps it, and now there are two renderings of the same value with different rules. A masked username has the same effect on access logs and audit trails. Neither host nor username grants access on its own, so masking them buys nothing while making the system less legible.
The rule that holds up: wrap what grants access, not what names it. Passwords, tokens, API keys, signing keys, private keys, session secrets and webhook signing secrets are credentials. Hosts, ports, usernames, account identifiers, region names, bucket names and queue names are identifiers. The grey area is small — a URL containing a password, an account identifier that is also a bearer token in some API — and in the grey area, split the field so the credential part is wrapped and the rest is not.
Problem 2: unwrapping everywhere to make code compile
# ANTI-PATTERN: the wrapper is technically present and buys nothing
key = settings.api_token.get_secret_value()
headers = {"Authorization": f"Bearer {key}"}
log.info("calling %s with key %s", url, key) # the whole point defeated
A wrapper only helps if the unwrapped value has a short life. Code that unwraps at the top of a function and then passes the raw string around has the same exposure as a plain str field, plus the illusion of protection — which is worse than no protection, because it stops people looking.
Keep unwrapping adjacent to the call that requires a raw string, ideally inside a small helper that takes the settings object and returns a configured client. Then the raw value exists only inside that helper’s frame, the call sites never see it, and a grep for get_secret_value returns a short list you can review. If that list is growing, the fix is another helper rather than more discipline.
Problem 3: assuming the wrapper survives serialisation everywhere
# ANTI-PATTERN: a "safe" dump that is not
payload = settings.model_dump() # SecretStr masks, but a DSN type does not
requests.post(url, json=payload) # the URL's password goes over the wire
SecretStr masks itself in a dump; a parsed URL type does not, because it is a structured string type whose serialised form includes every component. A model containing both looks uniformly protected and is not. The same asymmetry applies to any custom type you add that wraps a string without overriding its serialisation.
The reliable check is empirical rather than by inspection: serialise the model in a test with recognisable sentinel values and assert that none of them appear in the output. That catches the DSN case, catches a newly added plain field, and keeps catching them as the model grows — which no amount of reviewing annotations does.
Secure implementation
# config.py — wrapped where it matters, plain where it helps
from pydantic import SecretStr, PostgresDsn, field_serializer
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(extra="forbid")
# 1. identifiers: plain, so every log line about them is readable
environment: str = "local"
db_host: str
db_port: int = 5432
db_user: str
bucket_name: str
# 2. credentials: wrapped, so nothing renders them by accident
db_password: SecretStr
api_token: SecretStr
webhook_signing_secret: SecretStr
# 3. a composite value: parsed for validation, masked for serialisation
read_replica_url: PostgresDsn | None = None
@field_serializer("read_replica_url")
def _mask_dsn(self, value: PostgresDsn | None) -> str | None:
if value is None:
return None
return str(value).replace(value.password, "***") if value.password else str(value)
@property
def database_target(self) -> str:
# 4. the safe description operators actually want in a log
return f"{self.db_user}@{self.db_host}:{self.db_port}"
# clients.py — one helper per client keeps unwrapping out of application code
import psycopg
from config import Settings
def database(settings: Settings) -> psycopg.Connection:
# 5. the raw value exists only inside this frame
return psycopg.connect(
host=settings.db_host,
user=settings.db_user,
password=settings.db_password.get_secret_value(),
dbname="app",
)
Splitting the connection into components rather than a single URL is worth noticing. It means the password is the only wrapped part, the rest is readable everywhere, and database_target can be logged freely — no serialiser, no masking, no risk. Where a driver insists on a URL, assemble it inside the helper so the assembled string never escapes.
The field_serializer on the optional replica URL covers the case where a composite value is unavoidable. Note that it handles None explicitly: a serialiser that assumes a value is present raises on the default configuration, and that failure will appear the first time somebody dumps settings in an environment without a replica, which is usually the local one.
Migrating an existing model
Changing a field from str to SecretStr is a typed change, which makes the migration tractable: the type checker lists every place that used the value as a string. That list is the work. Most entries are a single get_secret_value() insertion; a few reveal that the value was being passed somewhere it should not have been, which is the migration finding a real bug rather than creating work.
Do it one field at a time, starting with the highest-value credential, and add the sentinel-dump test on the first migration so the property is protected from then on. Resist the temptation to convert every field in one commit — a large diff of mechanical unwrap insertions is exactly the sort of change reviewers skim, and one of those insertions is likely to be in a place that should have been restructured instead.
Two side effects are worth anticipating. Tests that compared the field to a string will fail, which is the wrapper doing its job; update them to compare through get_secret_value() in the one test that genuinely verifies unwrapping, and to compare masked forms elsewhere. And code that put the value in an f-string will now silently produce ********** rather than failing — so run the application, not just the type checker, before considering the migration done. That is the one failure mode the type system cannot see, because f"{value}" is valid for any object.
Where a field is genuinely ambiguous, the tiebreaker is what happens if it leaks. If the answer is “we rotate something”, wrap it. If the answer is “somebody learns our database is called app-prod-1”, leave it plain and spend the effort on something that matters. That test resolves nearly every argument about a borderline field in a sentence, and it keeps the wrapped set small enough that its members are obviously significant.
Gotchas and version-specific behaviour
SecretStrdoes not compare equal to a plain string; that is deliberate and catches accidental comparisons.- An f-string containing a
SecretStrrenders the mask, so a migrated field can silently change a URL or header value. model_dump()masksSecretStrbut not parsed URL types — add afield_serializerfor those.SecretBytesexists for binary material and moves the decode into the model rather than into a named local.- Pydantic v1’s
SecretStrbehaved similarly but the serialisation controls differ; check dump behaviour after a v1-to-v2 migration. - Type checkers catch every string use of a newly wrapped field, which is what makes the migration finite.
- A wrapper protects rendering, not memory — the value is a plain string on the object for anything that can read the process.
A last consideration is what the wrapper communicates to the next person reading the model. An annotation is documentation that cannot go stale: api_token: SecretStr states that this value is a credential, that it must not be logged, and that unwrapping it is a deliberate act — three facts that a comment would assert and nothing would enforce. That signalling value is a reason to be consistent even for a credential you are confident nobody would log, because consistency is what makes the exceptions visible.
Production parity checklist
- Every field that grants access is wrapped; every field that names something is not.
get_secret_value()appears once per client, inside a helper, and the count is stable over time.- Composite values with an embedded credential have a masking serialiser or are split into components.
- A test serialises the model with sentinel values and asserts none appear in the output.
- A safe descriptive property exists for anything operators routinely need to see.
- The application has been run, not just type-checked, after any field was migrated to a wrapper.
Frequently asked questions
Is there a performance cost to SecretStr?
Negligible. It is a thin wrapper around a string, constructed once per process when settings are built, and reading the attribute is an ordinary attribute lookup. The only measurable cost would come from unwrapping in a tight loop, and that is a design smell rather than a performance problem — a loop calling get_secret_value() per iteration should hoist the client construction instead. Performance is never the reason to leave a credential unwrapped.
Should a username be a SecretStr?
Usually not. A username is an identifier that operators legitimately need in logs to diagnose problems — which account connected, which service account made a call — and masking it makes diagnosis harder without meaningfully reducing risk, because a username alone grants no access. Wrap the thing that grants access, not the thing that names it. The exception is a system where the username is itself a bearer credential, which does exist in some legacy APIs; there, the correct answer is to treat it as a credential and to plan a move to something better.
Why does comparing a SecretStr to a string fail?
The wrapper does not define equality against a plain string, which is deliberate — it prevents an accidental comparison from silently succeeding when it should not, and it forces any test that needs the raw value to say so explicitly. In practice this matters most in tests, where the fix is to compare get_secret_value() in the single test that verifies unwrapping and to compare masked forms elsewhere so a failure message never renders the credential into a CI log that is more widely readable than production.
How do I migrate an existing model to SecretStr?
Change the annotation, fix the call sites the type checker flags, and add a test asserting the value does not appear in a repr or a dump of the model. The type checker turns the migration into a finite list rather than a search, which is what makes this tractable on a large codebase. Do one field per commit, starting with the most sensitive, and run the application afterwards — an f-string that used to interpolate the value now interpolates the mask, and that is the one break the type checker cannot see.
Key takeaways
The decision is one question per field: does this value grant access, or does it name something? Credentials get wrapped; identifiers stay plain so operators can read them in logs and traces. Masking identifiers has a real cost in diagnosability and no compensating benefit, and it tends to produce a second, unwrapped rendering of the same value somewhere else — which is the outcome the wrapper existed to prevent.
Wrapping only helps in proportion to how briefly the unwrapped value exists, so keep get_secret_value() inside client helpers and watch the count of call sites as a health metric. Add a masking serialiser for composite values like URLs, prove the whole thing with a sentinel-based dump test rather than by reviewing annotations, and run the application after any migration to catch the f-strings the type checker cannot see. See the secret types and redaction topic for how this fits with the rest of the disclosure surface.