16 KiB
PROJECT: Site Passcode Security — API-Verified Auth
Last updated: 2026-04-10 Status: Backend work in progress — frontend pending backend completion Priority: High — passcodes for trusted/administrator access currently in localStorage plaintext
Problem Statement
When a user loads the Aether frontend, the site bootstrap response includes access_code_kv_json — a JSON object containing all passcodes for all access levels (administrator, trusted, public, authenticated). The frontend stores this verbatim in $ae_loc.site_access_code_kv, which is persisted in localStorage.
Result: Anyone with DevTools → Application → Local Storage can see every passcode for every access level on any Aether site. For public/authenticated this is low risk, but for trusted and administrator this is a real exposure — these passcodes can grant control over event data, badge printing, edit mode, etc.
The passcode check (handle_check_access_type_passcode in e_app_access_type.svelte) is entirely local — it reads the cached values and compares directly. No API call is made. The backend already has a /authenticate_passcode endpoint that verifies server-side, but it needs the fixes described below before the frontend can rely on it.
Source of Truth
site.access_code_kv_json is the single source of truth for all passcodes. The v_site_domain DB view joins this field from the site table — there is no separate copy. Both the bootstrap response and /authenticate_passcode read from the same data.
Threat Model
| Threat | Current | After Fix |
|---|---|---|
| Attacker inspects localStorage | Sees all passcodes in plaintext | Sees a JWT (opaque, no passcode) |
| Attacker uses stolen trusted passcode | Trivial if they have localStorage access | Still possible if they enter the passcode — unavoidable |
| Attacker replays an old passcode after it changes | Works forever (cached value never refreshes) | Fails — API verifies against current DB value |
Attacker tampers with access_type in localStorage |
Grants apparent permission but API calls still fail | Same — access_type is still persisted separately |
| Passcode reuse across sessions | Works indefinitely | JWT TTL enforces session expiry per role |
| Offline / API-unavailable entry | Works (local cache) | Blocked — requires API to verify |
The fundamental constraint
Passcode-based access is inherently weaker than username/password login with a hashed credential. The system's security model layers passcode access below user login, and API calls themselves are still gated by x-aether-api-key + x-account-id. The passcode primarily controls what the frontend shows and some API-level permission gates for trusted routes.
Proposed Solution: API-Verified Passcode + JWT Session
Core idea
- Never send passcodes to the client. The frontend stops reading/storing
access_code_kv_jsonfrom the bootstrap response. - Passcode entry triggers an API call to
/authenticate_passcode. API verifies server-side against the DB. - On success, the API returns a JWT — the JWT contains the role, account context, and expiry.
- Store the JWT in
$ae_loc.jwt(already a field, already wired into$ae_api). - On page reload, check the JWT's
eat(expires-at) claim locally (base64 decode, no signature verification needed client-side). If expired, drop to anonymous. If valid,access_typeis already persisted in$ae_loc.
Session restore on reload
access_typestill persists in localStorage (no change here)- The JWT is the proof that the access was legitimately granted and is still valid
- On page load: decode JWT payload (base64 the middle segment), check
eatvsDate.now()/1000 - If JWT expired → reset
access_typeto anonymous, clear JWT - If JWT valid → no action needed,
access_typeis already correct
This gives session expiry without a network call on every page load.
TTL Per Role — Decided
| Access Level | JWT TTL | Notes |
|---|---|---|
super |
8 hours | Highest privilege |
manager |
24 hours | |
administrator |
48 hours | |
trusted |
48 hours | Onsite staff — covers multi-day events |
public |
24 hours | |
authenticated |
12 hours | |
anonymous |
N/A | No passcode |
Caching Decision
No passcode caching. Every passcode entry makes one API call. The JWT handles session persistence — no passcode ever touches localStorage. Performance impact is only at the moment of entry (~50–150ms), which is acceptable for a once-per-session action.
Backend Changes Required
Note: The backend fixes described below have been implemented and tested in the aether_api_fastapi repository (the /authenticate_passcode endpoint now uses explicit role priority, returns a full passcode JWT with auth_type: 'passcode', applies per-role TTLs, and validates passcode length). Frontend changes can proceed once the backend deployment with these fixes is available.
Phase 2 status: Not started — removing access_code_kv_json from the public site model remains pending.
File: aether_api_fastapi/app/routers/api.py
The /authenticate_passcode endpoint exists and is structurally correct but has four issues that must be fixed before the frontend migrates to using it.
Fix 1: Passcode matching must use explicit priority order
Current (wrong):
for role, code in access_codes.items(): # dict insertion order — not guaranteed
if str(code) == str(passcode):
matched_role = role
break
Required:
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']
matched_role = None
for role in ROLE_PRIORITY:
code = access_codes.get(role)
if code and str(code) == str(passcode):
matched_role = role
break
This ensures that if a config mistake causes two roles to share a passcode, the higher-privilege role always wins. It also makes the intent explicit and independent of JSON storage order.
Fix 2: JWT payload must include all six role flags
Current (incomplete):
payload = {
'account_id': account_id_random,
'administrator': (matched_role == 'administrator'),
'manager': (matched_role == 'manager'),
'super': (matched_role == 'super'),
# trusted / public / authenticated missing
...
}
Required:
payload = {
'account_id': account_id_random,
'super': (matched_role == 'super'),
'manager': (matched_role == 'manager'),
'administrator': (matched_role == 'administrator'),
'trusted': (matched_role == 'trusted'),
'public': (matched_role == 'public'),
'authenticated': (matched_role == 'authenticated'),
'json_str': json.dumps({
'auth_type': 'passcode', # distinguishes from user login JWTs
'site_id': site_id,
'role': matched_role # canonical role string — frontend uses this
})
}
The auth_type: 'passcode' marker is critical — it allows the frontend and any future backend consumers to distinguish a passcode JWT from a user login JWT.
Fix 3: Per-role TTL
Current:
token = sign_jwt(
secret_key=settings.JWT_KEY,
ttl=3600 * 24, # hardcoded 24h for all roles
**payload
)
Required:
ROLE_TTL = {
'super': 8 * 3600, # 8 hours
'manager': 24 * 3600, # 24 hours
'administrator': 48 * 3600, # 48 hours
'trusted': 48 * 3600, # 48 hours
'public': 24 * 3600, # 24 hours
'authenticated': 12 * 3600, # 12 hours
}
token = sign_jwt(
secret_key=settings.JWT_KEY,
ttl=ROLE_TTL[matched_role],
**payload
)
Fix 4: Add minimum length validation to passcode field
Current:
passcode: str = Field(..., description="The passcode to verify")
Required:
passcode: str = Field(..., min_length=5, description="The passcode to verify")
This matches the frontend's 5-character trigger and prevents empty/trivial submissions.
Complete corrected endpoint (for reference)
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']
ROLE_TTL = {
'super': 8 * 3600,
'manager': 24 * 3600,
'administrator': 48 * 3600,
'trusted': 48 * 3600,
'public': 24 * 3600,
'authenticated': 12 * 3600,
}
class PasscodeAuthRequest(BaseModel):
"""Request model for site-based passcode authentication."""
site_id: str = Field(..., description="Random string ID of the site")
passcode: str = Field(..., min_length=5, description="The passcode to verify")
@router.post('/authenticate_passcode', response_model=Resp_Body_Base)
async def authenticate_passcode(
auth_req: PasscodeAuthRequest,
response: Response = Response,
):
"""
Passcode-to-JWT Endpoint.
Verifies a passcode against site.access_code_kv_json (single source of truth —
v_site_domain joins from the same site record).
Returns a signed JWT with the site's account context, full role flags, and
a per-role TTL. The jwt.json_str.auth_type='passcode' field distinguishes
this token from a user login JWT.
"""
site_id = auth_req.site_id
passcode = auth_req.passcode
# 1. Look up the site record
search_data = {'id_random': site_id}
if record := sql_select(table_name='site', data=search_data):
# 2. Parse access codes
access_codes_raw = record.get('access_code_kv_json')
access_codes = {}
if access_codes_raw:
try:
access_codes = json.loads(access_codes_raw) if isinstance(access_codes_raw, str) else access_codes_raw
except Exception as e:
log.error(f"Failed to parse access_code_kv_json for site {site_id}: {e}")
# 3. Verify passcode in explicit priority order (highest privilege wins)
matched_role = None
for role in ROLE_PRIORITY:
code = access_codes.get(role)
if code and str(code) == str(passcode):
matched_role = role
break
if matched_role:
log.info(f"Auth Success: Verified '{matched_role}' passcode for site {site_id}")
# 4. Resolve account context
account_id_random = record.get('account_id_random')
if not account_id_random:
if account_id_int := record.get('account_id'):
account_id_random = get_id_random(record_id=account_id_int, table_name='account')
# 5. Mint JWT with complete role flags and per-role TTL
payload = {
'account_id': account_id_random,
'super': (matched_role == 'super'),
'manager': (matched_role == 'manager'),
'administrator': (matched_role == 'administrator'),
'trusted': (matched_role == 'trusted'),
'public': (matched_role == 'public'),
'authenticated': (matched_role == 'authenticated'),
'json_str': json.dumps({
'auth_type': 'passcode',
'site_id': site_id,
'role': matched_role
})
}
token = sign_jwt(
secret_key=settings.JWT_KEY,
ttl=ROLE_TTL[matched_role],
**payload
)
return mk_resp(
data={'jwt': token, 'account_id': account_id_random, 'role': matched_role},
response=response
)
else:
log.warning(f"Auth Failed: Invalid passcode for site {site_id}")
return mk_resp(data=False, status_code=401, response=response, status_message="Invalid passcode.")
else:
log.warning(f"Auth Failed: Site {site_id} not found.")
return mk_resp(data=False, status_code=404, response=response, status_message="Site not found.")
Backend Phase 2 (follow-up — not blocking frontend)
Remove access_code_kv_json from the Site_Domain_Base response model (site_domain_models.py). This ensures passcodes are never sent to the client even if future code reads from the bootstrap. Requires confirming no other endpoint consumers rely on access_code_kv_json being in the base response before making this change.
Frontend Changes Required
These depend on the backend fixes above being deployed first.
1a. src/lib/app_components/e_app_access_type.svelte
Replace handle_check_access_type_passcode entirely. The new version:
- Is
async - Adds
auth_pending: boolean = $state(false)andauth_error: string | null = $state(null) - Uses a direct
fetchcall (NOTpost_object— avoids triggering the session-expired banner on a 401) - On success: sets
$ae_loc.access_type = data.role, stores$ae_loc.jwt = data.jwt, triggersprocess_permission_checkas before - On 401: shows inline error, clears
entered_passcode, resetschecked_passcode = nullto allow retry - On network error: shows inline connection error
- Clears
auth_errorwhenentered_passcodechanges
API call shape:
POST /authenticate_passcode
Content-Type: application/json
x-aether-api-key: <from $ae_api.headers['x-aether-api-key']>
Body: { site_id: $ae_loc.site_id, passcode: entered_passcode }
Add to template (near the passcode input):
{#if auth_pending}
<Loader size="1em" class="animate-spin text-gray-400" />
{/if}
{#if auth_error}
<span class="text-error-500 text-xs">{auth_error}</span>
{/if}
1b. src/routes/+layout.ts
Stop caching passcodes from bootstrap — remove line ~394:
// ae_loc_init['site_access_code_kv'] = json_data.access_code_kv_json || {};
Add passcode JWT expiry check — after the block around line 84 where ae_loc_json.jwt is read, add:
// Enforce passcode JWT TTL on page load.
// Decodes the JWT payload (base64, no secret needed) and resets access to anonymous if expired.
// User login JWTs (auth_type !== 'passcode') are left untouched.
if (ae_loc_json?.jwt) {
try {
const parts = ae_loc_json.jwt.split('.');
if (parts.length === 3) {
const jwt_payload = JSON.parse(atob(parts[1]));
const json_str = typeof jwt_payload.json_str === 'string'
? JSON.parse(jwt_payload.json_str)
: jwt_payload.json_str;
if (json_str?.auth_type === 'passcode' && jwt_payload.eat < Date.now() / 1000) {
// Passcode JWT has expired — revoke access
ae_loc_json.jwt = null;
ae_loc_json.access_type = 'anonymous';
}
}
} catch {
// Malformed JWT — leave untouched, let existing handling deal with it
}
}
1c. src/lib/stores/ae_stores__auth_loc_defaults.ts (cleanup)
Remove site_access_code_kv from the AuthLocState interface and the auth_loc_defaults object. The field is unused after 1a. Confirm no other component reads from it first (current grep: only e_app_access_type.svelte uses it — confirmed).
Migration Notes
- Users with existing localStorage will still have
site_access_code_kvcached — this is harmless after the frontend stops reading it. No forced cache clear needed. - Existing persisted
access_typeis unaffected — users keep their current session level until their JWT expires or they manually clear storage. - The
$ae_loc.jwtfield is already used by the user login flow. Theauth_type: 'passcode'marker injson_strensures the expiry logic only targets passcode sessions, not user login sessions.
Files Affected
| File | Repo | Change |
|---|---|---|
app/routers/api.py |
aether_api_fastapi |
Backend — do first. Priority ordering, full JWT payload, per-role TTL, min_length on passcode |
app/models/site_domain_models.py |
aether_api_fastapi |
Phase 2: remove access_code_kv_json from public model |
src/lib/app_components/e_app_access_type.svelte |
aether_app_sveltekit |
Replace local check with async API call; loading/error UI |
src/routes/+layout.ts |
aether_app_sveltekit |
Stop caching passcodes; add JWT expiry check |
src/lib/stores/ae_stores__auth_loc_defaults.ts |
aether_app_sveltekit |
Cleanup: remove site_access_code_kv |
documentation/AE__Permissions_and_Security.md |
aether_app_sveltekit |
Update passcode auth section to reflect new flow |