40 KiB
Phase 07.3: Security — ES256 Algorithm Upgrade - Research
Researched: 2026-06-05 Domain: JWT cryptographic algorithm upgrade, ECDSA P-256, FastAPI lifespan startup hooks, Vue 3 login form Confidence: HIGH
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
D-01: Both JWT_PRIVATE_KEY and JWT_PUBLIC_KEY are required env vars. Both store the PEM key base64-encoded as a single line — shell-safe, no newline-escape complications. config.py decodes each value before passing to PyJWT.
D-02: create_access_token and create_password_reset_token sign with the private key using algorithm="ES256". decode_access_token and decode_password_reset_token verify with the public key using algorithms=["ES256"].
D-03: settings.secret_key is removed from all JWT code after ES256 is in place. It is no longer used for JWT signing. The SECRET_KEY env var stays in docker-compose for any future non-JWT HMAC needs (Phase 7.4 fingerprinting), but services/auth.py must not reference it.
D-04: On FastAPI lifespan startup, compare the jwt_algorithm key in the system_settings table (or its absence) against the current algorithm "ES256". If the stored value differs (e.g., was "HS256" or is missing), bulk-revoke all RefreshToken rows by executing UPDATE refresh_tokens SET revoked = true WHERE revoked = false, then upsert jwt_algorithm = "ES256" into system_settings. This is idempotent — safe to restart.
D-05: Use the existing system_settings upsert pattern from backend/services/ai_config.py (seed_system_settings_from_env) as the reference for reading/writing system settings in the startup hook.
D-06: Document key generation as a Python one-liner in README.md using the cryptography library (already a project dependency). The snippet prints both base64-encoded keys ready to paste into .env. No extra tooling required.
D-07: docker-compose.yml references ${JWT_PRIVATE_KEY} and ${JWT_PUBLIC_KEY} from the .env file — same pattern as the existing ${SECRET_KEY}. No placeholder values in the repo.
D-08: Access token TTL: 15 minutes — unchanged and non-negotiable (CLAUDE.md).
D-09: Default refresh token TTL: 16 hours (new config setting refresh_token_expire_hours: int = 16).
D-10: "Remember me" opt-in: 30 days (existing refresh_token_expire_days: int = 30 kept as-is).
D-11: POST /api/auth/login request body gains remember_me: bool = False. create_refresh_token gains remember_me: bool = False param — selects between timedelta(hours=settings.refresh_token_expire_hours) and timedelta(days=settings.refresh_token_expire_days).
D-12: LoginView.vue gains a "Stay signed in for 30 days" checkbox that passes remember_me to authStore.login(). The store passes it through to the API call.
Claude's Discretion
None specified.
Deferred Ideas (OUT OF SCOPE)
- Phase 7.4 — Token fingerprinting / token binding: Add
fgp(fingerprint) claim =hmac(key, User-Agent + Accept-Language)[:16]. - Key rotation ceremony: Dual-key overlap period for production P-256 key rotation. </user_constraints>
Project Constraints (from CLAUDE.md)
| Directive | Impact on Phase 7.3 |
|---|---|
| JWT access token in Pinia memory only — never localStorage | No change; access token delivery pattern unchanged |
| Access token TTL: 15 minutes maximum (non-negotiable) | D-08 locked; access_token_expire_minutes stays at 15 |
| Refresh token: httpOnly Strict cookie; rotated on every use | Cookie-setting helpers must pass remember_me through |
Admin endpoints never return password_hash, credentials_enc, or document content |
Not directly relevant; no new admin endpoints |
| No raw string interpolation in DB queries | Bulk revoke uses text("UPDATE …") — parameterized; no user input injected |
Service layer raises ValueError, never HTTPException |
ES256 config validation in config.py validates before service layer, not inside it |
No function in services/auth.py imports HTTPException |
ES256 key decode failures must raise ValueError or let startup crash with a descriptive message |
Existing test gate: pytest -v zero failures before phase advances |
test_settings_has_jwt_config asserts refresh_token_expire_days == 30 — this passes since we keep that field; new refresh_token_expire_hours assertion needed |
Summary
Phase 7.3 replaces HS256 with ES256 (ECDSA P-256) across the four JWT signing/verification sites in backend/services/auth.py, adds a first-boot startup hook to bulk-revoke all active refresh tokens whenever the algorithm changes, and introduces a "remember me" opt-in that changes the default refresh token TTL from 30 days to 16 hours.
The algorithmic change is surgical. PyJWT 2.13.0 (installed and verified on this system) supports ES256 natively via jwt.algorithms.ECAlgorithm. The cryptography library (already a project dependency, >=41.0.0) provides the P-256 key generation primitives. No new packages are required. The upgrade touches 4 lines in services/auth.py, 3 settings in config.py, 2 lines in docker-compose.yml, and one README.md key-generation snippet.
The startup token rotation is idempotent. It uses the system_settings table's provider_id uniqueness to track the deployed algorithm (provider_id = "jwt_algorithm", model_name = "ES256"). On every boot, the lifespan reads this value; if it differs from "ES256", it bulk-revokes all active refresh tokens and writes the new value. Subsequent restarts skip the revocation. The bulk revoke uses a single raw UPDATE refresh_tokens SET revoked = true WHERE revoked = false via session.execute(text(...)) — no Python-layer iteration, consistent with the project pattern for bulk operations.
The "remember me" change is a controlled TTL expansion. The default session drops from 30 days to 16 hours (refresh_token_expire_hours: int = 16 added to config). An opt-in remember_me: bool = False field on the LoginRequest Pydantic model controls which TTL path create_refresh_token takes. Both the cookie max_age and the expires_at DB column must be updated consistently.
Primary recommendation: Implement in three independent but ordered waves: (1) ES256 key infrastructure + 4 signing sites, (2) startup rotation check, (3) remember-me TTL + frontend checkbox. Each wave is independently testable.
Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|---|---|---|---|
| JWT signing key pair storage | Backend config (env vars) | — | Private key must never leave the backend; env var is the standard secret injection pattern |
| JWT encoding (ES256) | Backend service (services/auth.py) |
— | All token creation is encapsulated in the service layer per CLAUDE.md rules |
| JWT decoding / verification | Backend deps (deps/auth.py → services/auth.py) |
— | get_current_user dependency calls decode_access_token from service layer |
| Startup token rotation | Backend lifespan (main.py) |
DB (system_settings) |
Lifespan owns startup tasks; DB tracks idempotency state |
| Refresh token TTL selection | Backend service (services/auth.py:create_refresh_token) |
Backend API (api/auth.py:login) |
Service makes TTL decision; API passes the flag through |
| Cookie max_age alignment | Backend API (api/auth.py:_set_refresh_cookie) |
— | Cookie must mirror DB expires_at TTL exactly |
| "Stay signed in" checkbox UI | Frontend view (LoginView.vue) |
Frontend store (stores/auth.js) |
View owns form state; store owns API call forwarding |
Standard Stack
Core (already installed — no new packages)
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| PyJWT | >=2.8.0 (2.13.0 installed) |
JWT encode/decode with ES256 | Official Python JWT library; ECAlgorithm class confirmed present; ES256 encode/decode verified working on this system [VERIFIED: pip show PyJWT] |
| cryptography | >=41.0.0 |
P-256 key generation and PEM serialization | Already project dependency for HKDF/Fernet; ec.generate_private_key(ec.SECP256R1()) and serialization.Encoding.PEM APIs verified working on this system [VERIFIED: runtime test] |
No New Packages Required
All cryptographic primitives for ES256 are covered by the two libraries above, both already pinned in requirements.txt. No additional installs needed for this phase.
Version verification:
pip show PyJWT # → 2.13.0
pip show cryptography # → already installed (41.x or higher)
Package Legitimacy Audit
No new packages are introduced in this phase. All cryptographic work uses PyJWT (already pinned) and cryptography (already pinned). Slopcheck is not required.
| Package | Status | Notes |
|---|---|---|
| PyJWT | Already in requirements.txt — no change | ES256 support confirmed [VERIFIED: runtime test] |
| cryptography | Already in requirements.txt — no change | P-256 key generation confirmed [VERIFIED: runtime test] |
Packages removed due to slopcheck verdict: none Packages flagged as suspicious: none
Architecture Patterns
System Architecture Diagram
.env file
JWT_PRIVATE_KEY (base64 PEM)
JWT_PUBLIC_KEY (base64 PEM)
│
▼
config.py (Settings)
jwt_private_key: str → base64.b64decode().decode() → PEM string
jwt_public_key: str → base64.b64decode().decode() → PEM string
refresh_token_expire_hours: int = 16
refresh_token_expire_days: int = 30
│
├──► services/auth.py
│ create_access_token() jwt.encode(payload, private_pem, algorithm="ES256")
│ decode_access_token() jwt.decode(token, public_pem, algorithms=["ES256"])
│ create_password_reset_token() jwt.encode(payload, private_pem, algorithm="ES256")
│ decode_password_reset_token() jwt.decode(token, public_pem, algorithms=["ES256"])
│ create_refresh_token(remember_me=False)
│ TTL = timedelta(hours=16) if not remember_me
│ = timedelta(days=30) if remember_me
│
├──► main.py lifespan (startup)
│ ES256 rotation check:
│ SELECT model_name FROM system_settings WHERE provider_id = 'jwt_algorithm'
│ if missing or != 'ES256':
│ UPDATE refresh_tokens SET revoked = true WHERE revoked = false
│ UPSERT system_settings (provider_id='jwt_algorithm', model_name='ES256')
│
└──► api/auth.py
LoginRequest.remember_me: bool = False
_set_refresh_cookie(response, raw_token, remember_me)
max_age = 30*86400 if remember_me else 16*3600
Frontend:
LoginView.vue
[ ] Stay signed in for 30 days ← new checkbox
submitPassword() / submitTotp() / submitBackupCode()
→ authStore.login(email, password, { ..., rememberMe: bool })
stores/auth.js
login(email, password, options)
api.login({ ..., remember_me: options.rememberMe ?? false })
Recommended Project Structure
No new directories. Changes are concentrated in:
backend/
├── config.py # +jwt_private_key, +jwt_public_key, +refresh_token_expire_hours
├── services/auth.py # 4 jwt.encode/decode sites; create_refresh_token TTL
├── main.py # lifespan: +ES256 rotation block
├── api/auth.py # LoginRequest +remember_me; _set_refresh_cookie +remember_me
frontend/
├── src/views/auth/LoginView.vue # +checkbox, +rememberMe ref, pass through to store
└── src/stores/auth.js # login() +rememberMe param pass-through
Pattern 1: ES256 Key Decode in config.py
# Source: verified via runtime test + PyJWT docs
import base64
class Settings(BaseSettings):
# ... existing fields ...
jwt_private_key: str = "" # base64-encoded PEM — required in production
jwt_public_key: str = "" # base64-encoded PEM — required in production
refresh_token_expire_hours: int = 16 # default short session (D-09)
# refresh_token_expire_days: int = 30 already exists — keep unchanged
def get_jwt_private_key_pem(self) -> str:
"""Decode base64-encoded private key PEM string for PyJWT."""
return base64.b64decode(self.jwt_private_key).decode()
def get_jwt_public_key_pem(self) -> str:
"""Decode base64-encoded public key PEM string for PyJWT."""
return base64.b64decode(self.jwt_public_key).decode()
Alternative (simpler, no property): Decode inline at call site in services/auth.py:
import base64
private_pem = base64.b64decode(settings.jwt_private_key).decode()
public_pem = base64.b64decode(settings.jwt_public_key).decode()
Either approach is acceptable. The inline pattern avoids adding methods to Settings and is consistent with how other base64 fields (like cloud credentials) are handled in this codebase.
Pattern 2: ES256 JWT Signing/Verification (4 sites)
# Source: verified via runtime test — PyJWT 2.13.0 + cryptography 41.x
import base64, jwt
from config import settings
# BEFORE (HS256):
# return jwt.encode(payload, settings.secret_key, algorithm="HS256")
# payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
# AFTER (ES256):
def create_access_token(user_id: str, role: str) -> str:
# ... payload construction unchanged ...
private_pem = base64.b64decode(settings.jwt_private_key).decode()
return jwt.encode(payload, private_pem, algorithm="ES256")
def decode_access_token(token: str) -> dict:
public_pem = base64.b64decode(settings.jwt_public_key).decode()
try:
payload = jwt.decode(token, public_pem, algorithms=["ES256"])
except jwt.ExpiredSignatureError as exc:
raise ValueError("Token has expired") from exc
except jwt.PyJWTError as exc:
raise ValueError(f"Invalid token: {exc}") from exc
# ... type check unchanged ...
return payload
The same pattern applies identically to create_password_reset_token (sign) and decode_password_reset_token (verify).
Pattern 3: Startup Token Rotation (lifespan hook)
# Source: D-04/D-05 from CONTEXT.md; modeled after seed_system_settings_from_env pattern
from sqlalchemy import text, select
from db.models import SystemSettings
async def _rotate_tokens_on_algorithm_change(session: AsyncSession) -> None:
"""Idempotent ES256 migration: bulk-revoke tokens if algorithm changed."""
stmt = select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
result = await session.execute(stmt)
row = result.scalar_one_or_none()
current_alg = row.model_name if row is not None else None
if current_alg == "ES256":
return # Already migrated — skip
# Algorithm changed or first boot: bulk-revoke all active refresh tokens
await session.execute(
text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false")
)
# Upsert the algorithm marker
if row is None:
import uuid as _uuid
session.add(SystemSettings(
id=_uuid.uuid4(),
provider_id="jwt_algorithm",
model_name="ES256",
context_chars=0,
is_active=False,
api_key_enc=None,
base_url=None,
))
else:
row.model_name = "ES256"
await session.commit()
This function slots into main.py:lifespan after the AI seed block, wrapped in the same try/except pattern (so a missing system_settings table before migrations doesn't crash startup).
Pattern 4: remember_me TTL Selection
# Source: D-09/D-10/D-11 from CONTEXT.md
async def create_refresh_token(
session: AsyncSession,
user_id: uuid.UUID,
remember_me: bool = False, # NEW PARAM
) -> str:
raw = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw.encode()).hexdigest()
now = datetime.now(timezone.utc)
# Select TTL based on remember_me
if remember_me:
ttl = timedelta(days=settings.refresh_token_expire_days) # 30 days
else:
ttl = timedelta(hours=settings.refresh_token_expire_hours) # 16 hours
row = RefreshToken(
id=uuid.uuid4(),
user_id=user_id,
token_hash=token_hash,
expires_at=now + ttl,
revoked=False,
)
session.add(row)
await session.commit()
return raw
The _set_refresh_cookie helper also needs a remember_me param to set max_age consistently:
def _set_refresh_cookie(response: Response, raw_token: str, remember_me: bool = False) -> None:
max_age = (
settings.refresh_token_expire_days * 86400
if remember_me
else settings.refresh_token_expire_hours * 3600
)
response.set_cookie(
key="refresh_token",
value=raw_token,
httponly=True,
secure=True,
samesite="strict",
path="/api/auth/refresh",
max_age=max_age,
)
Pattern 5: Key Generation One-Liner (for README.md)
# Source: verified via runtime test — cryptography library
python3 -c "
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
import base64
k = ec.generate_private_key(ec.SECP256R1())
priv = base64.b64encode(k.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())).decode()
pub = base64.b64encode(k.public_key().public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)).decode()
print(f'JWT_PRIVATE_KEY={priv}')
print(f'JWT_PUBLIC_KEY={pub}')
"
This outputs two single-line values ready to paste into .env. The cryptography library is already a project dependency so no install is needed.
Anti-Patterns to Avoid
- Caching decoded PEM strings as module-level globals: If the env var changes between tests or deployments and the module was already imported, stale keys would be used. Decode inline at each call site or in a
@cached_propertythat is tied to the Settings instance. - Passing
settings.secret_keyto any JWT function after ES256 is deployed: D-03 explicitly removes this coupling. If any site still usessecret_key, tests will catch it (HS256 tokens rejected by ES256 verifier). - Calling
create_refresh_tokeninrotate_refresh_tokenwithout forwardingremember_me:rotate_refresh_tokencallscreate_refresh_token(session, row.user_id)internally. This internal call should NOT receiveremember_me— token rotation preserves the session type from the original login. The newremember_meparam defaults toFalse, so the rotation call is unchanged; a rotated token always gets the default 16-hour TTL (which is acceptable security behavior — the client gets a fresh cookie with the new token). - Inserting a
jwt_algorithmrow intosystem_settingswithis_active=True: This would make the AI provider loader (load_provider_config) potentially attempt to use "jwt_algorithm" as a provider. Always insert withis_active=False. - Using
model_namefor arbitrary key-value storage without documenting the overloading: TheSystemSettings.model_namecolumn isNOT NULL TEXT— it can hold "ES256" safely. Document in the startup function's docstring that this is a metadata row, not an AI provider row.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| ECDSA key generation | Custom byte manipulation | ec.generate_private_key(ec.SECP256R1()) from cryptography |
cryptography handles FIPS-compliant randomness, correct curve encoding, and PEM serialization |
| ES256 JWT signing | Manual ECDSA signature + base64url encode | jwt.encode(payload, pem, algorithm="ES256") from PyJWT |
PyJWT handles all RFC 7518 compliance, header formatting, and signature encoding |
| ES256 JWT verification | Manual signature extraction + ECDSA verify | jwt.decode(token, pem, algorithms=["ES256"]) from PyJWT |
PyJWT validates expiry, algorithm, and signature in one call; algorithm-restriction list prevents downgrade attacks |
| Bulk token revocation | Python loop loading all RefreshToken rows | session.execute(text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false")) |
Single SQL statement vs. N Python-layer round trips; consistent with project bulk-operation pattern |
Key insight: The cryptography + PyJWT combination handles all ECDSA P-256 complexity. There is no case in this phase where custom cryptographic code is appropriate.
Common Pitfalls
Pitfall 1: PEM Newlines in Environment Variables
What goes wrong: A raw PEM string contains newlines. Shell .env files and Docker Compose do not handle multi-line values well — the variable gets truncated at the first newline.
Why it happens: PEM format is inherently multi-line. Naive JWT_PRIVATE_KEY=$(cat private.pem) breaks.
How to avoid: Store as base64-encoded single line (D-01). base64.b64encode(pem_bytes).decode() produces a single-line string with no newlines (standard base64 with no padding issues for a ~PKCS8 key). Decode at use with base64.b64decode(settings.jwt_private_key).decode().
Warning signs: binascii.Error: Invalid base64-encoded string or jwt.exceptions.DecodeError: Invalid header string at startup.
Pitfall 2: Algorithm Confusion / Downgrade Attack During Transition
What goes wrong: Old clients hold HS256 tokens. After ES256 deployment, decode_access_token now calls jwt.decode(token, pub_pem, algorithms=["ES256"]). An HS256 token presented to the ES256 verifier raises InvalidAlgorithmError which maps to 401 — correct behavior.
Why it happens: Clients with a valid HS256 access token (up to 15 min old) will get 401 until their token expires. This is expected and correct — they will auto-refresh via the httpOnly cookie, which triggers the rotation. The startup hook bulk-revokes all refresh tokens, forcing re-login.
How to avoid: Accept this brief disruption (max 15 minutes for any in-flight session). Document in README.md. No dual-algorithm support is needed since the bulk-revoke forces re-login on first boot.
Warning signs: Users report "logged out" immediately after deploy — this is expected.
Pitfall 3: SystemSettings Model Incompatibility for jwt_algorithm Row
What goes wrong: SystemSettings.model_name is NOT NULL with default="". context_chars is NOT NULL with default=8000. Inserting a jwt_algorithm marker row requires satisfying these constraints without meaningful values.
Why it happens: The table was designed for AI provider config, not generic key-value storage.
How to avoid: Insert with model_name="ES256", context_chars=0, is_active=False, api_key_enc=None, base_url=None. This satisfies all NOT NULL constraints. Use is_active=False so the AI provider loader (load_provider_config) never returns this row. Verified against the model definition at db/models.py line 340.
Warning signs: sqlalchemy.exc.IntegrityError on NOT NULL violation if model_name is omitted.
Pitfall 4: _set_refresh_cookie Called in Refresh Endpoint Without remember_me
What goes wrong: _set_refresh_cookie is called in both the login endpoint (new remember_me param) and the refresh_token endpoint (token rotation, no remember_me). If the refresh endpoint passes remember_me=False (default), rotating a 30-day session silently downgrades it to 16 hours.
Why it happens: Token rotation in rotate_refresh_token doesn't know what TTL the original token had — expires_at is on the old row, which is being revoked.
How to avoid: For the rotation endpoint, preserve the original cookie's max_age by reading the remaining TTL from the new token's expires_at and computing max_age = int((row.expires_at - now).total_seconds()) on the newly-created row. Alternatively, accept that rotated sessions default to 16 hours (acceptable since the user can re-login with remember_me). The CONTEXT.md does not require preservation of remember_me state across rotation, so the simpler approach (default TTL on rotation) is acceptable.
Warning signs: Users who selected "remember me" get logged out after 16 hours despite the checkbox.
Decision required by planner: Either preserve the TTL on rotation (complex — needs to pass remember_me through rotate_refresh_token) or accept 16-hour default on rotation (simple and acceptable). The CONTEXT.md is silent on this. The simpler approach is recommended for this phase.
Pitfall 5: Existing Test Asserts on Settings Value
What goes wrong: backend/tests/test_task1_models_config.py:31 asserts settings.refresh_token_expire_days == 30. Adding refresh_token_expire_hours does not break this assertion since the field value is unchanged.
Why it happens: Phase 2 locked the TTL value in a test. Phase 7.3 adds a new field but doesn't change the old one.
How to avoid: The test continues to pass. A new test should assert settings.refresh_token_expire_hours == 16 and that refresh_token_expire_days remains 30.
Warning signs: Test file test_task1_models_config.py — check that no assertion fails after adding refresh_token_expire_hours.
Pitfall 6: Lifespan Startup Order — System Settings Table May Not Exist
What goes wrong: The startup rotation check queries system_settings. On a fresh container before migrations run, the table doesn't exist and the query raises.
Why it happens: Docker healthchecks ensure DB is ready, but the migration step may not have run yet (developer workflow).
How to avoid: Wrap the ES256 rotation block in the same try/except Exception pattern already used for seed_system_settings_from_env in main.py:lifespan (lines 172–180). Log a warning and skip if the table is missing. On the next boot (after migrations), the check runs successfully.
Warning signs: sqlalchemy.exc.ProgrammingError: relation "system_settings" does not exist at startup.
Code Examples
Verified patterns from official sources:
Full ES256 Signing Chain (verified at research time)
# Source: verified via runtime test on this system (PyJWT 2.13.0 + cryptography 41.x)
import base64, jwt, uuid, datetime as dt
private_pem = base64.b64decode(settings.jwt_private_key).decode()
public_pem = base64.b64decode(settings.jwt_public_key).decode()
# Sign
now = dt.datetime.now(dt.timezone.utc)
payload = {
"sub": str(user_id),
"role": role,
"typ": "access",
"jti": str(uuid.uuid4()), # Phase 7.2 adds this claim; preserved in ES256
"iat": now,
"exp": now + timedelta(minutes=settings.access_token_expire_minutes),
}
token = jwt.encode(payload, private_pem, algorithm="ES256")
# Verify
decoded = jwt.decode(token, public_pem, algorithms=["ES256"])
# algorithms=["ES256"] is a whitelist — HS256 tokens raise InvalidAlgorithmError (verified)
Bulk Revoke + System Settings Upsert (startup)
# Source: D-04/D-05 from CONTEXT.md; SQLAlchemy text() pattern from codebase
from sqlalchemy import text, select
from db.models import SystemSettings
async with AsyncSessionLocal() as session:
result = await session.execute(
select(SystemSettings).where(SystemSettings.provider_id == "jwt_algorithm")
)
row = result.scalar_one_or_none()
if row is None or row.model_name != "ES256":
await session.execute(
text("UPDATE refresh_tokens SET revoked = true WHERE revoked = false")
)
if row is None:
session.add(SystemSettings(
id=uuid.uuid4(),
provider_id="jwt_algorithm",
model_name="ES256",
context_chars=0,
is_active=False,
))
else:
row.model_name = "ES256"
await session.commit()
LoginRequest with remember_me (backend)
# Source: CONTEXT.md D-11; modeled on existing LoginRequest pattern
class LoginRequest(BaseModel):
email: EmailStr
password: str
totp_code: Optional[str] = None
backup_code: Optional[str] = None
remember_me: bool = False # NEW: opt-in to 30-day session
Login form checkbox (Vue 3 Options API → script setup)
<!-- Source: CONTEXT.md D-12; follows existing LoginView.vue pattern -->
<!-- Add inside the password step form, before the submit button: -->
<div class="flex items-center gap-2">
<input
v-model="rememberMe"
id="remember-me"
type="checkbox"
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<label for="remember-me" class="text-sm text-gray-600">
Stay signed in for 30 days
</label>
</div>
// In script setup, add:
const rememberMe = ref(false)
// Update submitPassword():
const result = await authStore.login(email.value, password.value, {
rememberMe: rememberMe.value,
})
Auth store login() update
// Source: CONTEXT.md D-12; extends existing stores/auth.js login() action
async function login(email, password, options = {}) {
// ...
const data = await api.login({
email,
password,
totp_code: options.totpCode ?? null,
backup_code: options.backupCode ?? null,
remember_me: options.rememberMe ?? false, // NEW
})
// ... rest unchanged
}
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| HS256 (symmetric HMAC) | ES256 (ECDSA P-256) asymmetric | This phase | Public key cannot forge tokens; leaked public key (safe to distribute) cannot impersonate users |
| 30-day default refresh TTL | 16-hour default; 30-day opt-in | This phase | Sessions expire overnight by default; lower risk for unattended sessions |
algorithms=["HS256"] in decode |
algorithms=["ES256"] whitelist |
This phase | PyJWT whitelist prevents algorithm confusion attacks; HS256 tokens immediately rejected |
Deprecated/outdated:
settings.secret_keyas JWT signing key: After this phase,services/auth.pyno longer referencessecret_keyfor JWT operations. The field stays in config for Phase 7.4 HMAC fingerprinting use.
Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|---|---|---|
| A1 | The model_name field of SystemSettings being repurposed as a value store for jwt_algorithm will not trigger unexpected behavior in load_provider_config() since is_active=False prevents selection |
Startup rotation | Low — verified that load_provider_config filters by is_active IS TRUE; is_active=False row is never returned as active provider |
| A2 | rotate_refresh_token() (called for normal token rotation, not the startup bulk-revoke) calls create_refresh_token(session, row.user_id) without remember_me — new default TTL (16h) will apply on every rotation |
Pitfall 4 | Low risk for security (shorter is better); risk for UX (remember-me users get 16h after first rotation). Accepted per Pitfall 4 note. |
| A3 | Phase 7.2 adds a jti claim to access tokens. Since Phase 7.3 depends on Phase 7.2, the create_access_token payload will already contain jti when Phase 7.3 is implemented. The ES256 upgrade must preserve the jti field. |
Code Examples | Low — verified: PyJWT preserves all custom claims through ES256 encode/decode. |
If this table is empty: All claims in this research were verified or cited. — Not applicable; three low-risk assumptions documented above.
Open Questions
-
TTL preservation across refresh token rotation
- What we know:
rotate_refresh_tokencallscreate_refresh_tokeninternally with noremember_meparam (default=False → 16 hours) - What's unclear: Should a user who selected "stay signed in 30 days" get a new 30-day token on each rotation, or is 16-hour degradation acceptable?
- Recommendation: Accept 16-hour degradation on rotation for Phase 7.3 (simpler implementation). If users report confusion, Phase 7.4 or a follow-up can add TTL preservation by reading original
expires_ator adding aremember_mecolumn toRefreshToken.
- What we know:
-
Test
test_settings_has_jwt_configassertion onrefresh_token_expire_days == 30- What we know: The test at
backend/tests/test_task1_models_config.py:31asserts the existing field equals 30. This field is NOT changed. - What's unclear: Nothing — this test continues to pass. A new test should assert
refresh_token_expire_hours == 16. - Recommendation: Add assertion for
refresh_token_expire_hoursin the same test function or a new one.
- What we know: The test at
Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|---|---|---|---|---|
| PyJWT | ES256 encoding/decoding | Yes | 2.13.0 | — |
| cryptography | P-256 key generation, PEM serialization | Yes | (>=41.0.0 pinned) | — |
| Python 3.12 | All backend code | Yes | 3.12 | — |
| PostgreSQL | system_settings table for algorithm tracking |
Yes (Docker Compose) | 17 | — |
Missing dependencies with no fallback: None. Missing dependencies with fallback: None.
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | pytest + pytest-asyncio |
| Config file | backend/pytest.ini or pyproject.toml |
| Quick run command | cd backend && pytest tests/test_auth_api.py tests/test_task1_models_config.py -x -v |
| Full suite command | cd backend && pytest -v |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| ES256-01 | create_access_token uses ES256 algorithm |
unit | pytest tests/test_auth_es256.py::test_access_token_uses_es256 -x |
❌ Wave 0 |
| ES256-02 | decode_access_token rejects HS256 tokens |
unit | pytest tests/test_auth_es256.py::test_hs256_token_rejected -x |
❌ Wave 0 |
| ES256-03 | create_password_reset_token uses ES256 |
unit | pytest tests/test_auth_es256.py::test_reset_token_uses_es256 -x |
❌ Wave 0 |
| ES256-04 | Startup rotation bulk-revokes on algorithm change | integration | pytest tests/test_auth_es256.py::test_startup_rotation_revokes_tokens -x |
❌ Wave 0 |
| ES256-05 | Startup rotation is idempotent (second boot skips) | integration | pytest tests/test_auth_es256.py::test_startup_rotation_idempotent -x |
❌ Wave 0 |
| RM-01 | Default login issues 16-hour refresh token | integration | pytest tests/test_auth_es256.py::test_default_ttl_16_hours -x |
❌ Wave 0 |
| RM-02 | remember_me=True issues 30-day refresh token | integration | pytest tests/test_auth_es256.py::test_remember_me_ttl_30_days -x |
❌ Wave 0 |
| RM-03 | remember_me=True sets cookie max_age=30*86400 | integration | pytest tests/test_auth_es256.py::test_remember_me_cookie_max_age -x |
❌ Wave 0 |
| CFG-01 | Settings has jwt_private_key, jwt_public_key, refresh_token_expire_hours |
unit | pytest tests/test_task1_models_config.py::test_settings_has_jwt_config -x |
✅ (needs extension) |
Sampling Rate
- Per task commit:
cd backend && pytest tests/test_auth_es256.py tests/test_task1_models_config.py -x -v - Per wave merge:
cd backend && pytest -v - Phase gate: Full suite green before
/gsd:verify-work
Wave 0 Gaps
backend/tests/test_auth_es256.py— covers ES256-01 through RM-03 (8 new tests)- Extend
backend/tests/test_task1_models_config.py::test_settings_has_jwt_configto assertrefresh_token_expire_hours == 16
Security Domain
Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | yes | ES256 asymmetric tokens; algorithms=["ES256"] whitelist prevents downgrade |
| V3 Session Management | yes | Refresh token TTL split (16h default / 30d opt-in); bulk-revoke on algorithm change |
| V4 Access Control | no | No new access control logic |
| V5 Input Validation | yes | remember_me: bool = False typed Pydantic field; base64.b64decode validates key format at startup |
| V6 Cryptography | yes | ECDSA P-256 (256-bit EC key) via cryptography library; no hand-rolled crypto |
Known Threat Patterns for JWT + ES256
| Pattern | STRIDE | Standard Mitigation |
|---|---|---|
| Algorithm confusion: HS256 token presented to ES256 verifier | Spoofing | algorithms=["ES256"] whitelist in jwt.decode() — raises InvalidAlgorithmError (verified) |
| None algorithm attack | Spoofing | PyJWT explicitly requires algorithms list; algorithms=["ES256"] prevents alg=none |
| Leaked public key token forgery | Spoofing | ES256 asymmetric design: public key cannot sign — forgery requires the private key |
| Private key exposure in env | Elevation of Privilege | Base64 single-line in .env; .gitignore enforces exclusion; no defaults in code (D-07) |
| Stale HS256 tokens after upgrade | Spoofing | Bulk refresh token revocation forces re-login; 15-min access token TTL self-expires |
| remember_me session indefinitely reused | Elevation of Privilege | RFC 9700 family revocation on reuse still applies; 30-day TTL is bounded |
Security Gate Requirements
Before phase advances:
bandit -r backend/— zero HIGH severity findingspip audit— zero critical/high CVEsnpm audit --audit-level=high— zero high/critical- HS256 token presented to ES256 verifier → 401 (negative test
test_hs256_token_rejected) - Startup rotation test: tokens revoked on algorithm change, skipped on same algorithm
services/auth.pygrep confirms zero references tosettings.secret_keyafter implementation
Sources
Primary (HIGH confidence)
- PyJWT 2.13.0 installed on system —
ECAlgorithm, ES256 encode/decode verified via runtime execution cryptographylibrary installed —ec.generate_private_key(ec.SECP256R1()),serialization.Encoding.PEMverified via runtime executionbackend/services/auth.py— 4 HS256 sites confirmed at lines 99, 109, 132, 141 (read directly)backend/config.py— current settings layout confirmed (read directly)backend/main.py— lifespan function structure confirmed at line 136 (read directly)backend/services/ai_config.py—seed_system_settings_from_envupsert reference pattern (read directly)backend/db/models.py—SystemSettingsschema confirmed at lines 340–377;RefreshToken.revokedat line 102 (read directly)backend/api/auth.py—LoginRequest,_set_refresh_cookie, login handler confirmed (read directly)frontend/src/views/auth/LoginView.vue— step-based login form,authStore.login()at line 228 (read directly)frontend/src/stores/auth.js—login()action confirmed (read directly).planning/phases/07.3-security-es256-algorithm-upgrade-inserted/07.3-CONTEXT.md— all 12 decisions confirmed (read directly)backend/tests/test_task1_models_config.py:31— existing assertion onrefresh_token_expire_days == 30confirmed (read directly)
Secondary (MEDIUM confidence)
None required — all findings verified from code or runtime tests.
Tertiary (LOW confidence)
None — no claims rely on WebSearch-only sources.
Metadata
Confidence breakdown:
- Standard stack: HIGH — PyJWT ES256 and cryptography P-256 generation both verified via runtime tests on the installed versions
- Architecture: HIGH — all 6 change sites read directly from codebase; data flow confirmed end-to-end
- Pitfalls: HIGH — pitfalls derived from concrete code inspection (existing constraints, test assertions, and the SystemSettings schema)
Research date: 2026-06-05 Valid until: 2026-07-05 (stable libraries; PyJWT API is backward-compatible since 2.0)