diff --git a/docs/plans/2026-03-01-config-download-token.md b/docs/plans/2026-03-01-config-download-token.md
new file mode 100644
index 0000000..62bcaa5
--- /dev/null
+++ b/docs/plans/2026-03-01-config-download-token.md
@@ -0,0 +1,506 @@
+# Config Download Token Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Add a per-config `download_token` field that allows downloading the generated ZIP without OIDC auth by POSTing the token in the request body.
+
+**Architecture:** A `download_token` (random 43-char URL-safe string) is added to the `Config` model and auto-generated on creation. The existing `POST /configs/{id}/generate` endpoint accepts an optional JSON body; if the token matches, access is granted regardless of OIDC session. A new `POST /configs/{id}/regenerate-token` endpoint (OIDC-only) replaces the token. The frontend shows the token on the Config Detail page with copy and regenerate buttons.
+
+**Tech Stack:** Python `secrets.token_urlsafe`, FastAPI optional body with `Optional` dependency, SQLAlchemy, Alembic, React + MUI.
+
+---
+
+### Task 1: Alembic migration — add `download_token` to configs
+
+**Files:**
+- Create: `backend/alembic/versions/0012_config_add_download_token.py`
+
+**Step 1: Create the migration file**
+
+```python
+"""add download_token to configs
+
+Revision ID: 0012
+Revises: 0011
+Create Date: 2026-03-01
+"""
+import secrets
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.sql import table, column
+
+revision = "0012"
+down_revision = "0011"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "configs",
+ sa.Column("download_token", sa.String(64), nullable=False, server_default=""),
+ )
+ # Backfill existing rows with unique tokens
+ configs = table("configs", column("id", sa.Integer), column("download_token", sa.String(64)))
+ conn = op.get_bind()
+ for row in conn.execute(sa.select(configs.c.id)):
+ conn.execute(
+ configs.update()
+ .where(configs.c.id == row.id)
+ .values(download_token=secrets.token_urlsafe(32))
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("configs", "download_token")
+```
+
+**Step 2: Verify migration runs (if you have a local DB)**
+
+```bash
+cd backend && alembic upgrade head
+```
+
+Expected: no errors, `configs` table gains a `download_token` column with non-empty values.
+
+**Step 3: Commit**
+
+```bash
+git add backend/alembic/versions/0012_config_add_download_token.py
+git commit -m "feat: migration 0012 — add download_token to configs"
+```
+
+---
+
+### Task 2: SQLAlchemy model — add `download_token` field
+
+**Files:**
+- Modify: `backend/app/models.py:21-39`
+
+**Step 1: Add the field to the `Config` model**
+
+In `backend/app/models.py`, add this import at the top alongside existing imports:
+
+```python
+import secrets
+```
+
+Then add `download_token` to the `Config` class after `updated_at`:
+
+```python
+download_token: Mapped[str] = mapped_column(
+ String(64),
+ nullable=False,
+ default=lambda: secrets.token_urlsafe(32),
+)
+```
+
+The `default=lambda: secrets.token_urlsafe(32)` ensures new configs created via SQLAlchemy get a token automatically.
+
+**Step 2: Commit**
+
+```bash
+git add backend/app/models.py
+git commit -m "feat: add download_token field to Config model"
+```
+
+---
+
+### Task 3: Pydantic schemas — expose token and add request body
+
+**Files:**
+- Modify: `backend/app/schemas.py`
+
+**Step 1: Add `download_token` to `ConfigOut`**
+
+In `backend/app/schemas.py`, update `ConfigOut` (currently lines 29-38) to add:
+
+```python
+class ConfigOut(BaseModel):
+ id: int
+ name: str
+ description: str
+ is_active: bool
+ created_at: datetime
+ updated_at: datetime
+ owner_id: int
+ download_token: str # ← add this line
+
+ model_config = {"from_attributes": True}
+```
+
+**Step 2: Add `GenerateRequest` body schema**
+
+Add this new class near the bottom of `backend/app/schemas.py`, before `GenerateOut`:
+
+```python
+# --- Generate request ---
+class GenerateRequest(BaseModel):
+ token: Optional[str] = None
+```
+
+`Optional` is already imported at line 3.
+
+**Step 3: Add `RegenerateTokenOut` response schema**
+
+```python
+class RegenerateTokenOut(BaseModel):
+ download_token: str
+```
+
+**Step 4: Commit**
+
+```bash
+git add backend/app/schemas.py
+git commit -m "feat: add download_token to ConfigOut and GenerateRequest schema"
+```
+
+---
+
+### Task 4: API — modify generate endpoint and add regenerate-token endpoint
+
+**Files:**
+- Modify: `backend/app/api/configs.py`
+
+**Step 1: Update imports**
+
+At the top of `backend/app/api/configs.py`, add `Optional` to the imports:
+
+```python
+from typing import Optional
+from fastapi import APIRouter, Depends, HTTPException, Response
+from fastapi.responses import StreamingResponse
+from sqlalchemy.orm import Session, selectinload
+from app import models, schemas
+from app.auth import get_current_user
+from app.database import get_db
+from app.shorewall_generator import ShorewallGenerator
+import io
+import secrets
+```
+
+**Step 2: Replace the `generate_config` endpoint**
+
+Replace the entire `generate_config` function (lines 79-115) with:
+
+```python
+@router.post("/{config_id}/generate")
+def generate_config(
+ config_id: int,
+ format: str = "json",
+ body: Optional[schemas.GenerateRequest] = None,
+ db: Session = Depends(get_db),
+ current_user: Optional[models.User] = Depends(get_current_user),
+):
+ token = body.token if body else None
+
+ # Determine access: OIDC session or matching download token
+ if current_user is not None:
+ # Authenticated via OIDC — enforce owner check
+ config = (
+ db.query(models.Config)
+ .filter(models.Config.id == config_id, models.Config.owner_id == current_user.id)
+ .first()
+ )
+ if not config:
+ raise HTTPException(status_code=404, detail="Config not found")
+ elif token:
+ # Token auth — no owner filter, just match token
+ config = (
+ db.query(models.Config)
+ .filter(models.Config.id == config_id, models.Config.download_token == token)
+ .first()
+ )
+ if not config:
+ raise HTTPException(status_code=401, detail="Invalid token")
+ else:
+ raise HTTPException(status_code=401, detail="Authentication required")
+
+ # Eagerly load relationships
+ config = (
+ db.query(models.Config)
+ .options(
+ selectinload(models.Config.zones),
+ selectinload(models.Config.interfaces).selectinload(models.Interface.zone),
+ selectinload(models.Config.policies).selectinload(models.Policy.src_zone),
+ selectinload(models.Config.policies).selectinload(models.Policy.dst_zone),
+ selectinload(models.Config.rules).selectinload(models.Rule.src_zone),
+ selectinload(models.Config.rules).selectinload(models.Rule.dst_zone),
+ selectinload(models.Config.snat_entries),
+ selectinload(models.Config.host_entries).selectinload(models.Host.zone),
+ selectinload(models.Config.params),
+ )
+ .filter(models.Config.id == config_id)
+ .first()
+ )
+
+ generator = ShorewallGenerator(config)
+
+ if format == "zip":
+ zip_bytes = generator.as_zip()
+ return StreamingResponse(
+ io.BytesIO(zip_bytes),
+ media_type="application/zip",
+ headers={"Content-Disposition": f"attachment; filename={config.name}-shorewall.zip"},
+ )
+
+ return generator.as_json()
+```
+
+**Important:** `get_current_user` currently raises a 401 if no cookie is present. You need to make it return `None` instead of raising when there is no cookie, so the generate endpoint can fall back to token auth. See Task 5 for that change — do Task 5 before testing this.
+
+**Step 3: Add the `regenerate_token` endpoint**
+
+Add this after `generate_config`:
+
+```python
+@router.post("/{config_id}/regenerate-token", response_model=schemas.RegenerateTokenOut)
+def regenerate_token(
+ config_id: int,
+ db: Session = Depends(get_db),
+ current_user: models.User = Depends(get_current_user),
+) -> dict:
+ config = _get_config_or_404(config_id, db, current_user)
+ config.download_token = secrets.token_urlsafe(32)
+ db.commit()
+ db.refresh(config)
+ return {"download_token": config.download_token}
+```
+
+**Step 4: Commit**
+
+```bash
+git add backend/app/api/configs.py
+git commit -m "feat: token auth on generate endpoint and regenerate-token endpoint"
+```
+
+---
+
+### Task 5: Auth — make `get_current_user` optional for the generate endpoint
+
+**Files:**
+- Modify: `backend/app/auth.py`
+
+The `generate_config` endpoint needs to accept requests with no OIDC cookie (token-only auth). Currently `get_current_user` raises 401 when the cookie is missing. Add an `optional` variant that returns `None` instead.
+
+**Step 1: Read current `get_current_user`**
+
+Open `backend/app/auth.py` and find the `get_current_user` function. It currently looks roughly like:
+
+```python
+async def get_current_user(
+ access_token: Optional[str] = Cookie(default=None),
+ db: Session = Depends(get_db),
+) -> models.User:
+ if not access_token:
+ raise HTTPException(status_code=401, ...)
+ ...
+ return user
+```
+
+**Step 2: Add `get_optional_user` that returns `None` when unauthenticated**
+
+Add this function after `get_current_user`:
+
+```python
+async def get_optional_user(
+ access_token: Optional[str] = Cookie(default=None),
+ db: Session = Depends(get_db),
+) -> Optional[models.User]:
+ if not access_token:
+ return None
+ try:
+ return await get_current_user(access_token=access_token, db=db)
+ except HTTPException:
+ return None
+```
+
+**Step 3: Update `generate_config` to use `get_optional_user`**
+
+In `backend/app/api/configs.py`, update the import and the dependency in `generate_config`:
+
+```python
+from app.auth import get_current_user, get_optional_user
+```
+
+And change the dependency in `generate_config`:
+
+```python
+current_user: Optional[models.User] = Depends(get_optional_user),
+```
+
+**Step 4: Commit**
+
+```bash
+git add backend/app/auth.py backend/app/api/configs.py
+git commit -m "feat: add get_optional_user for unauthenticated generate access"
+```
+
+---
+
+### Task 6: Frontend API client — add `regenerateToken`
+
+**Files:**
+- Modify: `frontend/src/api.ts:31-41`
+
+**Step 1: Add `regenerateToken` to `configsApi`**
+
+In `frontend/src/api.ts`, update the `configsApi` object:
+
+```typescript
+export const configsApi = {
+ list: () => api.get('/configs'),
+ create: (data: object) => api.post('/configs', data),
+ get: (id: number) => api.get(`/configs/${id}`),
+ update: (id: number, data: object) => api.put(`/configs/${id}`, data),
+ delete: (id: number) => api.delete(`/configs/${id}`),
+ generate: (id: number, format: 'json' | 'zip' = 'json') =>
+ api.post(`/configs/${id}/generate?format=${format}`, null, {
+ responseType: format === 'zip' ? 'blob' : 'json',
+ }),
+ regenerateToken: (id: number) =>
+ api.post<{ download_token: string }>(`/configs/${id}/regenerate-token`),
+}
+```
+
+**Step 2: Commit**
+
+```bash
+git add frontend/src/api.ts
+git commit -m "feat: add regenerateToken to configsApi"
+```
+
+---
+
+### Task 7: Frontend — display token with copy and regenerate on Config Detail
+
+**Files:**
+- Modify: `frontend/src/routes/ConfigDetail.tsx`
+
+**Step 1: Add state for `downloadToken`**
+
+In `ConfigDetail.tsx`, add a state variable near the other `useState` calls (around line 32):
+
+```typescript
+const [downloadToken, setDownloadToken] = useState('')
+```
+
+**Step 2: Load token in `useEffect`**
+
+The existing `useEffect` already fetches the config name. Update it to also set the token:
+
+```typescript
+useEffect(() => {
+ configsApi.get(configId).then((r) => {
+ setConfigName(r.data.name)
+ setDownloadToken(r.data.download_token)
+ })
+ // ... rest unchanged
+}, [configId])
+```
+
+**Step 3: Add the token UI block**
+
+Add these MUI imports at the top of the file (alongside existing imports):
+
+```typescript
+import IconButton from '@mui/material/IconButton'
+import InputAdornment from '@mui/material/InputAdornment'
+import OutlinedInput from '@mui/material/OutlinedInput'
+import FormControl from '@mui/material/FormControl'
+import InputLabel from '@mui/material/InputLabel'
+import Tooltip from '@mui/material/Tooltip'
+import ContentCopyIcon from '@mui/icons-material/ContentCopy'
+import RefreshIcon from '@mui/icons-material/Refresh'
+```
+
+Add a `handleRegenerate` function after `handleDelete`:
+
+```typescript
+const handleRegenerate = async () => {
+ if (!confirm('Regenerate the download token? The old token will stop working.')) return
+ const res = await configsApi.regenerateToken(configId)
+ setDownloadToken(res.data.download_token)
+}
+```
+
+In the JSX, insert this block between the breadcrumbs/generate-button row and the tabs card (i.e., after the closing `` of the top row, before the `
+
+ Download Token
+
+
+ navigator.clipboard.writeText(downloadToken)} edge="end">
+
+
+
+
+ }
+ inputProps={{ style: { fontFamily: 'monospace', fontSize: 12 } }}
+ />
+
+
+
+
+
+
+
+```
+
+**Step 4: Commit**
+
+```bash
+git add frontend/src/routes/ConfigDetail.tsx
+git commit -m "feat: show download token with copy and regenerate on Config Detail"
+```
+
+---
+
+### Task 8: Manual end-to-end verification
+
+**Step 1: Rebuild and restart**
+
+```bash
+docker compose up --build -d
+```
+
+**Step 2: Verify token appears in UI**
+
+Log in, open a config. You should see a "Download Token" field with a 43-character token.
+
+**Step 3: Test copy button**
+
+Click the copy icon — paste somewhere to confirm the token was copied.
+
+**Step 4: Test ZIP download with token**
+
+```bash
+curl -X POST "http://localhost/api/configs/1/generate?format=zip" \
+ -H 'Content-Type: application/json' \
+ -d '{"token": ""}' \
+ -o /tmp/shorewall.zip
+file /tmp/shorewall.zip # should say: Zip archive data
+```
+
+**Step 5: Test invalid token returns 401**
+
+```bash
+curl -s -o /dev/null -w "%{http_code}" -X POST \
+ "http://localhost/api/configs/1/generate?format=zip" \
+ -H 'Content-Type: application/json' \
+ -d '{"token": "wrong"}'
+# Expected: 401
+```
+
+**Step 6: Test regenerate**
+
+Click "Regenerate" in the UI, confirm the old token stops working and the new one works.
+
+**Step 7: Commit any fixups found during testing**