Session and Cookie Settings

docsfy supports two authentication paths: Bearer tokens for API clients and cookies for browser sessions.

src/docsfy/main.py

# 1. Check Authorization header (API clients)
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
    token = auth_header[7:]
    if token == settings.admin_key:
        is_admin = True
        username = "admin"
    else:
        user = await get_user_by_key(token)

# 2. Check session cookie (browser) -- opaque session token
if not user and not is_admin:
    session_token = request.cookies.get("docsfy_session")
    if session_token:
        session = await get_session(session_token)

SECURE_COOKIES is enabled by default, and session cookies are set as HttpOnly with SameSite=Strict.

src/docsfy/config.py

class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        extra="ignore",
    )

    admin_key: str = ""  # Required — validated at startup
    ai_provider: str = "claude"
    ai_model: str = "claude-opus-4-6[1m]"  # [1m] = 1 million token context window
    ai_cli_timeout: int = Field(default=60, gt=0)
    log_level: str = "INFO"
    data_dir: str = "/data"
    secure_cookies: bool = True  # Set to False for local HTTP dev

src/docsfy/main.py

response.set_cookie(
    "docsfy_session",
    session_token,
    httponly=True,
    samesite="strict",
    secure=settings.secure_cookies,
    max_age=SESSION_TTL_SECONDS,
)

src/docsfy/main.py

response.delete_cookie(
    "docsfy_session",
    httponly=True,
    samesite="strict",
    secure=settings.secure_cookies,
)

Warning: With default settings, browsers do not send Secure cookies over plain HTTP. If you run docsfy on http://localhost and keep SECURE_COOKIES=true, login may appear to work but follow-up requests can redirect back to /login.

SameSite Behavior

docsfy explicitly uses SameSite=Strict for session cookies, which blocks cookie sending in cross-site requests and helps reduce CSRF risk.

src/docsfy/main.py

response.set_cookie(
    "docsfy_session",
    session_token,
    httponly=True,
    samesite="strict",
    secure=settings.secure_cookies,
    max_age=SESSION_TTL_SECONDS,
)

tests/test_auth.py

async def test_login_cookie_has_samesite_strict(
    unauthed_client: AsyncClient,
) -> None:
    """Login cookie should have SameSite=strict."""
    response = await unauthed_client.post(
        "/login",
        data={"username": "admin", "api_key": TEST_ADMIN_KEY},
        follow_redirects=False,
    )
    set_cookie = response.headers.get("set-cookie", "")
    assert "samesite=strict" in set_cookie.lower()

Tip: For cross-origin integrations, use Authorization: Bearer <api_key> rather than relying on browser cookies.

TTL and Session Expiration

Session lifetime is 8 hours, enforced both in the cookie (max_age) and in server-side session lookup (expires_at > now).

src/docsfy/storage.py

SESSION_TTL_SECONDS = 28800  # 8 hours
SESSION_TTL_HOURS = SESSION_TTL_SECONDS // 3600

src/docsfy/storage.py

async def create_session(
    username: str, is_admin: bool = False, ttl_hours: int = SESSION_TTL_HOURS
) -> str:
    """Create an opaque session token."""
    token = secrets.token_urlsafe(32)
    token_hash = _hash_session_token(token)
    expires_at = datetime.now(timezone.utc) + timedelta(hours=ttl_hours)
    expires_str = expires_at.strftime("%Y-%m-%d %H:%M:%S")
    async with aiosqlite.connect(DB_PATH) as db:
        await db.execute(
            "INSERT INTO sessions (token, username, is_admin, expires_at) VALUES (?, ?, ?, ?)",
            (token_hash, username, 1 if is_admin else 0, expires_str),
        )

src/docsfy/storage.py

async def get_session(token: str) -> dict[str, str | int | None] | None:
    """Look up a session. Returns None if expired or not found."""
    token_hash = _hash_session_token(token)
    async with aiosqlite.connect(DB_PATH) as db:
        db.row_factory = aiosqlite.Row
        cursor = await db.execute(
            "SELECT * FROM sessions WHERE token = ? AND expires_at > datetime('now')",
            (token_hash,),
        )

src/docsfy/main.py

await cleanup_expired_sessions()

src/docsfy/storage.py

async def cleanup_expired_sessions() -> None:
    """Remove expired sessions.

    NOTE: This is called during application startup (lifespan) only.
    """
    async with aiosqlite.connect(DB_PATH) as db:
        await db.execute("DELETE FROM sessions WHERE expires_at <= datetime('now')")
        await db.commit()

Note: Expired sessions are rejected even before cleanup runs, because get_session() filters by expires_at on every lookup.

Opaque Session Tokens (Not API Keys)

Browser cookies carry a random session token, not the raw user/admin API key.

tests/test_auth.py

async def test_session_cookie_is_opaque_token(unauthed_client: AsyncClient) -> None:
    """The session cookie should NOT contain the raw API key."""
    response = await unauthed_client.post(
        "/login",
        data={"username": "admin", "api_key": TEST_ADMIN_KEY},
        follow_redirects=False,
    )
    assert "docsfy_session" in response.cookies
    cookie_value = response.cookies["docsfy_session"]
    assert cookie_value != TEST_ADMIN_KEY
    assert len(cookie_value) > 20

Local HTTP Development Adjustments

For local non-TLS development, explicitly disable secure cookies in .env.

.env.example

# Set to false for local HTTP development
# SECURE_COOKIES=false

docker-compose.yaml

services:
  docsfy:
    build: .
    ports:
      - "8000:8000"
    env_file: .env

Set this in your local .env:

SECURE_COOKIES=false

Then restart the app/container so Settings reloads the value.

Warning: Do not use SECURE_COOKIES=false outside local HTTP development.

Cookie/session behavior is covered in unit tests and executed via tox.

tox.toml

envlist = ["unittests"]

[env.unittests]
deps = ["uv"]
commands = [["uv", "run", "--extra", "dev", "pytest", "-n", "auto", "tests"]]

This includes tests for: - SameSite=strict cookie headers - opaque session cookie values - session invalidation on logout - expired-session cleanup behavior