---
title: "Public Share-Link Review Pattern"
aliases: [share-link-approval, client-sign-off-endpoint, signed-token-review]
tags: [tech-patterns, fastapi, react, auth, security]
created: 2026-05-06
updated: 2026-05-06
sources: [daily/2026-05-06.md]
---

# Public Share-Link Review Pattern

Signed-token public endpoint that lets a client approve or reject a deliverable without requiring a login.

## Key Takeaways

- Generate a signed URL (HMAC or JWT) and embed it in an email to the client — no account required.
- Backend validates the token and guards against double-submit with a 409 response.
- Frontend renders approve/reject buttons **only when** `job_status === 'pending_final_review'`; rendering unconditionally exposes actions on already-closed jobs.
- Use a `POST` endpoint for the decision action (idempotent-safe via 409 guard, not a `GET` — avoids link prefetchers triggering decisions).

## Endpoint Design

```python
# FastAPI
@router.post("/public/share/{token}/decision")
async def submit_decision(token: str, body: DecisionBody):
    payload = verify_share_token(token)          # raises 401 if invalid/expired
    job = await db.jobs.find_one({"_id": payload["job_id"]})

    if job["status"] != "pending_final_review":  # 409 guard — double-submit / stale link
        raise HTTPException(409, "Job is no longer awaiting review")

    await db.jobs.update_one(
        {"_id": job["_id"]},
        {"$set": {"status": "approved" if body.action == "approve" else "rejected"}}
    )
    return {"ok": True}

class DecisionBody(BaseModel):
    action: Literal["approve", "reject"]
```

## Token Generation

```python
import hmac, hashlib, base64, json, time

SECRET = os.environ["SHARE_LINK_SECRET"]

def create_share_token(job_id: str, expires_in: int = 7 * 86400) -> str:
    payload = {"job_id": job_id, "exp": int(time.time()) + expires_in}
    data = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
    sig = hmac.new(SECRET.encode(), data.encode(), hashlib.sha256).hexdigest()
    return f"{data}.{sig}"

def verify_share_token(token: str) -> dict:
    try:
        data, sig = token.rsplit(".", 1)
    except ValueError:
        raise HTTPException(401, "Invalid token")
    expected = hmac.new(SECRET.encode(), data.encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig):
        raise HTTPException(401, "Invalid token")
    payload = json.loads(base64.urlsafe_b64decode(data))
    if payload["exp"] < time.time():
        raise HTTPException(401, "Token expired")
    return payload
```

Alternatively use a signed JWT (`python-jose` / `PyJWT`) — same security properties, standard library.

## Frontend — Conditional Button Rendering

```tsx
// Only render when status is exactly 'pending_final_review'
{job.status === 'pending_final_review' && (
  <div className="flex gap-3">
    <Button onClick={() => submitDecision('approve')} variant="default">
      Approve
    </Button>
    <Button onClick={() => submitDecision('reject')} variant="destructive">
      Request Changes
    </Button>
  </div>
)}
```

> [!warning] Never render unconditionally
> If buttons are shown on a job that is already `approved` or `rejected`, a client re-opening the email link could trigger a duplicate action. The 409 guard on the backend prevents data corruption but the UI should be clean regardless.

## Share Link Construction

```python
BASE_URL = os.environ["FRONTEND_BASE_URL"]  # e.g. https://app.oliver.solutions

def build_share_url(job_id: str) -> str:
    token = create_share_token(job_id)
    return f"{BASE_URL}/review/{token}"
```

The URL is embedded in an email sent to the client. No auth cookie, no session — the token IS the credential.

## Security Checklist

- [ ] Token is HMAC-signed or JWT-signed (not a plain UUID)
- [ ] Token has an expiry (7–14 days typical for client review cycles)
- [ ] Backend 409 guard prevents double-submit race condition
- [ ] Frontend buttons hidden on non-pending jobs
- [ ] `SECRET` rotatable without invalidating active jobs (use `job_id` + short window)
- [ ] HTTPS only — token travels in URL, must not be downgraded

## Related

- [[wiki/concepts/double-submit-cookie-csrf]] — CSRF guard pattern for stateless JWT APIs
- [[wiki/concepts/etag-optimistic-locking]] — concurrent edit protection with ETag/If-Match
- [[wiki/tech-patterns/azure-ad-msal-auth]] — full SSO flow for authenticated users
