Appearance
Site Access Codes
A Site can be placed behind authentication in the Experience Manager. One of the available methods is a rotating code: a code and a matching URL that change every 60 seconds, so a QR code shown in a public space grants access to whoever is standing in front of it and to nobody who photographs it an hour later.
The Experience Manager renders the current code on the Site's Access tab. Anything that displays the QR code in the space itself — a signage player, a kiosk page, a printed-then-refreshed panel — is a separate implementation, and this document is the contract it has to satisfy.
INFO
This describes how codes are derived. Codes are validated by the atlas-sites Cloudflare Worker from the site manifest in Cloudflare KV. A renderer never validates anything; it only produces the URL a visitor follows.
Definitions
- Secret: A base32 string held by the Site, the single value everything else is derived from
- Step: The 60-second window a code belongs to, counted from the Unix epoch
- Code: An 8-digit number a visitor types at
/login - Token: A 26-character base32 string that forms the URL a QR code encodes
- Channel: Which of the Site's two profiles applies —
prodfor the live site,devfor preview deployments
Obtaining the secret
A renderer needs the Site's rotating-code secret and the hostname it signs in to. Both are shown on the Site's Access tab in the Experience Manager, and the secret is available through the API to a user with change permission on the Site:
GET /api/v2/sites/sites/{nanoid}/
json
{
"nanoid": "0000000000",
"hostname_default": "my-site.atlasss.site",
"auth": [
{
"channel": "dev",
"enabled": true,
"methods": ["password"],
"password": "abcde12345",
"totp_secret": null
},
{
"channel": "prod",
"enabled": true,
"methods": ["totp"],
"password": null,
"totp_secret": "AAAAAAAAAAAAAAAAAAAAAAAAAA"
}
]
}WARNING
The secret is equivalent to a permanent key for the site — anyone holding it can mint valid codes indefinitely. Provision it into a display once, over a channel you trust, and store it the way you would store a password. Rotating it in the Experience Manager invalidates every code derived from the old one immediately.
As a Secret
For a display running on an Experience Agent, the secret does not need to be copied by hand. While the rotating code is in use on the production channel, the Experience Manager publishes it as a managed Secret at:
/sites/{hostname}/TOTP_SECRET{hostname} is the site's default hostname, so a site at my-site.atlasss.site publishes to /sites/my-site.atlasss.site/TOTP_SECRET. Set it on a Template the way you would any other Secret and it arrives as an environment variable.
The Experience Manager owns this Secret for as long as the method is in use:
- Rotating the secret republishes it — a display reading the environment variable on each derivation picks up the new value without being reconfigured
- Turning the rotating code off, or disabling authentication, removes it
- Deleting the Site removes it
- It is marked Managed in the Secrets UI and cannot be edited or deleted there; change it on the Site's Access tab
The preview channel is not published. Preview hostnames rotate with every deployment, so there is no stable key to publish to — a preview display has to be given its secret directly.
Derive codes locally from the secret wherever you can. A display that computes its own codes keeps working through a network outage and rotates exactly on the boundary, which matters when the QR is the only way into the room's site.
Deriving a code
Both values come from an HMAC over the same step counter, so a renderer computes them without talking to anything.
step = floor(unix_seconds / 60)| Value | Construction |
|---|---|
| Code | RFC 6238 over HMAC-SHA1, dynamically truncated to 8 digits |
| Token | HMAC-SHA256, base32 encoded, first 26 characters, uppercase |
The code is standard RFC 6238 with T0 = 0, X = 60 and digits = 8 — any TOTP library produces it, provided you set the period to 60 seconds rather than the more common 30, and the digits to 8 rather than 6.
The token is not truncated in the entropy sense, which is why it, and not the code, is what a QR encodes: a URL sitting on the open internet should not be guessable, and 8 digits is only 10^8.
js
async function derive(secretBase32, step) {
const key = base32Decode(secretBase32) // -> Uint8Array
const counter = new DataView(new ArrayBuffer(8))
counter.setBigUint64(0, BigInt(step))
const sign = async (algorithm) => {
const k = await crypto.subtle.importKey(
'raw', key, { name: 'HMAC', hash: algorithm }, false, ['sign'])
return new Uint8Array(await crypto.subtle.sign('HMAC', k, counter.buffer))
}
// Code — RFC 6238 dynamic truncation
const sha1 = await sign('SHA-1')
const offset = sha1[sha1.length - 1] & 0x0f
const value = ((sha1[offset] & 0x7f) << 24 | sha1[offset + 1] << 16 |
sha1[offset + 2] << 8 | sha1[offset + 3]) % 1e8
const code = String(value).padStart(8, '0')
// Token — full digest, base32, first 26 characters
const token = base32Encode(await sign('SHA-256')).slice(0, 26)
return { code, token }
}The URL to encode
https://{hostname}/login/{token}That is the whole payload of the QR code — no query string, no additional parameters. A visitor who cannot scan can instead be given the code to type at https://{hostname}/login.
{hostname} is the Site's default hostname for the prod channel. Use no scheme other than https.
Refresh behaviour
Redraw when the step changes, not on an interval of your own — an interval started at an arbitrary moment drifts against the 60-second boundary and will spend part of every minute showing a code that is about to die.
next_change = (step + 1) * 60The Worker accepts the previous and next step as well as the current one, so a display whose clock is up to a minute out still works. Beyond that it fails, which makes an accurate clock a hard requirement:
- Keep the rendering device on NTP
- Do not derive from a clock that resets on reboot
- If a device cannot hold time, fall back to fetching the current token (below) rather than computing it
Fetching a token instead
WARNING
While this method is available, it's use is discouraged and not available via Nodes running with the Node Role. Pass the token to the Node via a Secret.
A thin client that cannot hold a secret may ask the Experience Manager for the current values. This endpoint requires an authenticated user with change permission on the Site — it is not suitable for an unattended public display, which has no user to authenticate as.
GET /api/v2/sites/sites/{nanoid}/access/token/?channel=prod
json
{
"channel": "prod",
"hostname": "my-site.atlasss.site",
"code": "00000000",
"token": "AAAAAAAAAAAAAAAAAAAAAAAAAA",
"url": "https://my-site.atlasss.site/login/AAAAAAAAAAAAAAAAAAAAAAAAAA",
"url_code": "https://my-site.atlasss.site/login?code=00000000",
"period": 60,
"expires_at": 1785945600.0
}| Key | Value |
|---|---|
code | The 8-digit code for the current step |
token | The path token for the current step |
url | The URL to encode in a QR code |
url_code | The equivalent URL carrying the typed code |
period | Seconds per step, always 60 |
expires_at | Unix timestamp the current step ends at — poll again here |
The QR image is not returned — render it from url on the client. Any QR library will do; the Experience Manager's own Access tab uses qrcode-generator at error correction level M, which keeps the code readable through a little glare or wear.
Returns 409 when the rotating code method is not enabled for the channel, or when the dev channel has no live preview deployment to sign in to.
Rendering requirements
- Do not cache the QR image. Set
Cache-Control: no-storeon anything serving it; a cached QR is a dead QR within a minute. - Size for the scan distance. A phone needs roughly 10× the module size in distance; err large, and keep the quiet zone (the border) intact.
- Keep contrast high. Dark modules on a white background. Inverted or tinted QR codes fail on older scanners.
- Show the code as well as the QR. Not every visitor scans, and
/loginaccepts the typed code. - Never render the secret, and never place it in a URL, a page source, or a log line.
What a code grants
A visitor who follows the URL receives a session cookie for that hostname lasting 1 hour. The session outlives the code that created it by design — the code proves presence once, and the session carries the visit.
Rotating the secret stops new codes being derived from the old one, but leaves sessions already issued alone. To end those, use Sign out all visitors on the Site's Access tab, which rotates the key those session cookies are signed with.
The site is publicly routable, so a code is exposed to anyone who can see the display. Place it where seeing it means being in the space.
