Secrets
Everything js_data() returns ends up in the page source, where any visitor can read it. A natural first attempt sends an API token along with the other settings:
API_TOKEN = os.environ["API_TOKEN"]
def js_data(self, kwargs, slots):
# Wrong: every visitor can read the token in the page source
return {"apiBase": "/api", "token": API_TOKEN}
The browser needs the data the token unlocks, not the token itself.
Keep tokens off the page
Wrap the token in Secret where you load it, read it on the server with get_secret_value(), and send the browser only the result:
import os
from citry import Component, Secret
API_TOKEN = Secret(os.environ["API_TOKEN"])
class Board(Component):
def template_data(self, kwargs, slots):
# Right: the token is used on the server only
token = API_TOKEN.get_secret_value()
return {"rows": load_rows(token)}
def js_data(self, kwargs, slots):
return {"apiBase": "/api"}
template = """
<ul>
<c-for each="row in rows">
<li>{{ row }}</li>
</c-for>
</ul>
"""
On the server a Secret is safe to pass around. In a template, {{ token }} shows ***. You can also pass it to a child component; annotate the child's Kwargs field as Secret[str] so a plain string passed there is rejected.
A plain string is not detected
Citry knows a value is secret only when you wrap it. A token kept in a plain str is sent like any other string, and so is the result of get_secret_value() once you return it from js_data() or write it into a template.
What rendering refuses
If a Secret ends up anywhere in the dicts and lists js_data() returns, Citry refuses to send it. Rendering the page raises a TypeError that names the component and the key, before any HTML is sent:
TypeError: Board.js_data()["token"] holds a citry.Secret, and Citry
never sends a Secret to the browser, where anyone could read it in
the page source. ...
Citry also refuses a Secret in these cases:
- A
Secretinside a dataclass instance, used as a dict key, or put in a set is refused, but theTypeErrorthen does not name the key. - Rendering fails with
ValueErrorwhencss_data()or a State field holds aSecret, whether or not the component has CSS.
Catch secrets early
citry --app ... check reports a Secret that js_data() returns before the page runs, as the citry.js-data.unsupported-type warning. It finds one when it can see where the value comes from:
- a module-level
Secretthatjs_data()puts in its result, such as{"token": API_TOKEN}, or aJsDatafield annotatedSecret[str]; - an attribute stored on a module or a plain class, such as
config.API_TOKEN. It skips an attribute whose reading runs code, such as a property or Django's lazysettings.
Without --types, citry check does not see a Secret that a call returns, such as get_token(). The Citry editor extension and citry check --types report one when the type checker can tell the call returns a Secret.
Less common cases
Cached child components
Passing a Secret to a child that uses the render cache raises CacheKeyError, because the default cache key is built from all of the child's kwargs and a Secret cannot be part of a key. Return values that are not secret, such as a user id, from the child's Cache.vary(), or leave the child uncached.
Pydantic Kwargs
When you pass a Secret to a child whose Kwargs is a Pydantic model, Pydantic's error for a plain string prints that string. Turn that off in the model's config:
from pydantic import BaseModel, ConfigDict
from citry import Secret
class Kwargs(BaseModel):
# Keeps a plain string passed as `token` out of the error
model_config = ConfigDict(hide_input_in_errors=True)
token: Secret[str]
See also
- Security for what else Citry does to keep a page safe.
- Component options for what
js_data()sends to the browser.