ADR-029: Early Access System — HMAC Verification, IP Throttling, and CLI Management
- Status: Accepted
- Date: 2026-04-16
- Depends on: ADR-022 (Persistent Storage Strategy), ADR-023 (Authentication Strategy)
- Enables: #316 (Early access system)
Context and Problem Statement
Bike Trip Planner is transitioning from a fully closed, CLI-provisioned invite-only model (ADR-023) to a controlled public beta. Prospective users need a way to express interest and request early access, while the administrator retains full control over who is granted access.
The mechanism must balance three competing concerns:
- Privacy — The system must not leak whether a given email address has already submitted a request (email enumeration is a significant privacy risk and enables targeted phishing).
- Anti-spam — A public-facing form is a natural target for automated abuse. Flooding the request queue with fake addresses would degrade operator experience and potentially exhaust outbound email quotas.
- Operator simplicity — The administrator is a single developer. The review workflow must be usable from a terminal without requiring a dedicated admin UI.
Decision Drivers
- No email enumeration — Responses must be uniform regardless of whether the email already exists in the system.
- Anti-spam / rate limiting — Automated submissions must be discouraged at the infrastructure level, not just the application level.
- CLI-first operator workflow — No admin UI required; a Symfony console command is sufficient.
- No token persistence for verification — Avoid storing short-lived verification tokens in the database; keep the
access_requesttable append-only and simple. - CSRF protection — The submission endpoint must be protected against cross-site request forgery.
- Stateless verification — HMAC signatures allow verification without a database round-trip.
Considered Options
Option A: Direct Invitation (Chosen for ADR-023, not applicable here)
The administrator manually creates accounts via CLI. No request flow exists.
Why not applicable: This model requires the administrator to proactively source users. For a beta program, the goal is to let users express interest organically and then review the backlog.
Option B: Email Verification with Database Token
User submits email → system stores a short-lived token in the database → user clicks a confirmation link → request is marked as confirmed.
Pros:
- Proves email address ownership before adding to the queue.
- Standard, well-understood pattern.
Cons:
- Requires a
verification_tokenstable or additional column — more schema complexity. - Token cleanup (expiry, garbage collection) adds operational burden.
- Does not solve email enumeration: a "token already sent" vs. "token sent" distinction leaks information.
- Adds friction for the user (two steps before appearing in the queue).
Option C: Open Registration with Post-Hoc Moderation
Anyone can create an account; the administrator reviews and approves/bans.
Pros:
- Maximum simplicity for the user.
- No waiting period before access.
Cons:
- Requires a full user provisioning and de-provisioning workflow.
- Spam accounts consume real resources before moderation.
- Loss of control — rejected accounts may have already explored the application.
- Incompatible with the invite-only model (ADR-023) where users are created exclusively by the administrator.
Option D: Waiting List with HMAC Verification (Chosen)
User submits email → system stores the request with pending status → HMAC-signed approval token is generated on demand by the CLI → administrator sends the approval link out-of-band → user clicks the link to activate their account (or the CLI promotes them directly).
Pros:
- Single database table (
access_request), append-only, no token column needed at submission time. - HMAC verification is stateless: the token encodes
(email, timestamp, status)and is verified by recomputing the signature — no token lookup required. - Uniform response policy eliminates email enumeration.
- CLI command covers the entire operator workflow: list, approve, reject.
- IP throttling (max 3 requests/hour) is implemented at the Symfony RateLimiter level — independent of application logic.
Cons:
- HMAC tokens have no built-in revocation (if the secret key leaks, all tokens are compromised). Mitigated by using a dedicated
APP_ACCESS_REQUEST_HMAC_SECRET(kept out of the database) that can be rotated independently ofAPP_SECRET. - The administrator must use the CLI or copy a link manually — no one-click dashboard.
Decision
Option D: Waiting list with HMAC verification, IP throttling, and Symfony CLI.
Data Model
A single Doctrine entity AccessRequest backed by an access_request table:
| Column | Type | Notes |
|---|---|---|
id |
UUID v7 | Primary key |
email |
VARCHAR(255) | Unique index (#[UniqueConstraint]); enforced at DB level via INSERT … ON CONFLICT (email) DO NOTHING to make duplicate submissions idempotent under concurrent load — see silent duplicate policy |
ip_address |
VARCHAR(45) | IPv4 or IPv6, stored for throttling audit |
status |
ENUM(pending, approved, rejected) |
Default: pending |
created_at |
TIMESTAMPTZ | Immutable, set on insert |
updated_at |
TIMESTAMPTZ | Updated on status change |
There is no token column. Verification tokens are generated on-demand by the CLI using HMAC and are never persisted.
Request Flow
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Browser │ │ Symfony │ │ Operator │
│ │ │ Backend │ │ (CLI) │
└─────┬─────┘ └─────┬────┘ └────┬─────┘
│ POST /access-requests │
│ {email, _csrf_token} │ │
│──────────────────────►│ │
│ │ Check IP rate │
│ │ limit (Redis) │
│ │ │
│ │ If email exists│
│ │ → silently │
│ │ ignore │
│ │ │
│ │ Insert row │
│ │ (status=pending│
│ 202 Accepted │ │
│◄──────────────────────│ │
│ │ │
│ │ app:access-request:list
│ │◄────────────────│
│ │ List pending │
│ │────────────────►│
│ │ │
│ │ app:access-request:approve <email>
│ │◄────────────────│
│ │ Generate HMAC │
│ │ token, update │
│ │ status=approved│
│ │────────────────►│
│ │ (prints link │
│ │ or sends mail)│
HMAC Token Design
Tokens are generated by the CLI app:access-request:approve command and are never stored in the database.
Token payload:
HMAC-SHA256(
key = APP_ACCESS_REQUEST_HMAC_SECRET,
input = "early_access:{email}:{approved_at_unix_timestamp}"
)
A dedicated environment variable (APP_ACCESS_REQUEST_HMAC_SECRET) is used rather than reusing APP_SECRET. Symfony's APP_SECRET already signs CSRF tokens, remember-me cookies, and password-reset payloads; binding approval links to it would force a full cross-subsystem invalidation every time any one of those contexts was rotated. A dedicated secret keeps the blast radius of a rotation (or a leak) scoped to the early-access activation feature. The variable must be documented alongside APP_SECRET in .env.dist and in the deployment runbook.
The approval link sent to the user encodes:
https://app.example.com/activate?email={email}&ts={approved_at_unix_timestamp}&sig={hmac_hex}
Verification (activation endpoint):
- Extract
email,ts,sigfrom the query string. - Reject if
tsis older than 72 hours (configurable TTL). - Recompute
HMAC-SHA256(APP_ACCESS_REQUEST_HMAC_SECRET, "early_access:{email}:{ts}")and compare tosigin constant time (hash_equals). - If valid: look up
AccessRequestbyemailandstatus=approved. Check whether aUserfor this email already exists — if so, skip creation and proceed directly to issuing credentials (idempotent activation). Otherwise, create theUserentity (same as the CLI provisioning path in ADR-023). - On success (user created or already existed): return a JWT access token and refresh token so the browser can log in immediately.
- On failure (invalid/expired HMAC, or
status≠approved): return202 Acceptedwith the same neutral body as the submission endpoint to prevent enumeration of approved accounts. The uniform-response anti-enumeration policy applies to the failure path only; the success path must emit credentials so the frontend can redirect into the authenticated app.
Silent Duplicate Policy
If a request is submitted for an email that already has a row in access_request (any status), the system:
- Does not insert a duplicate row.
- Does not return an error.
- Returns the same
202 Acceptedresponse as a fresh submission.
This prevents:
- User enumeration (attacker cannot distinguish "already submitted" from "first submission").
- Queue pollution from accidental double-submissions.
IP Throttling
Rate limiting is applied before any database access, at the Symfony RateLimiter layer:
- Limit: 3 requests per hour per IP address.
- Backend: Redis sliding window (
cache.rate_limiterpool). - Response on limit exceeded: same
202 Accepted— the response is indistinguishable from a successful submission to prevent probing. - Key:
early_access__{ip_address}(sanitized for Redis key safety).
CSRF Protection
The submission endpoint requires a valid Symfony CSRF token:
- Token ID:
early_access_request. - The frontend fetches the token via
GET /csrf-token/early_access_requestbefore rendering the form. - The API Platform State Processor validates the token via
CsrfTokenManagerInterfaceand returns422 Unprocessable Entityon failure (the only non-202 response the endpoint emits, since CSRF failure is not information an attacker can exploit for enumeration).
CLI Commands
All operator interactions go through a single Symfony console command group:
| Command | Description |
|---|---|
app:access-request:list |
List all requests, optionally filtered by --status=pending\|approved\|rejected. Outputs a formatted table with ID, email, IP, status, and date. |
app:access-request:approve <email> |
Set status to approved, generate HMAC approval link, and optionally send it via email (requires --send-email flag). |
app:access-request:reject <email> |
Set status to rejected. No email is sent. |
Uniform Response Policy
All outcomes of POST /access-requests return 202 Accepted with a neutral body:
{ "message": "Your request has been received. You will be notified if access is granted." }
This applies to:
- Successful insertion.
- Silent duplicate (email already exists).
- IP rate limit exceeded.
- (CSRF failure is the only exception:
422 Unprocessable Entity.)
Security Measures
| Threat | Mitigation |
|---|---|
| Email enumeration | Uniform 202 response for all outcomes |
| CSRF | Symfony CSRF token validated on every submission |
| Automated spam | IP throttling: max 3 req/hour via Redis RateLimiter |
| HMAC forgery | Dedicated APP_ACCESS_REQUEST_HMAC_SECRET (distinct from APP_SECRET), never stored in DB; constant-time comparison via hash_equals |
| Token replay | 72-hour TTL enforced at activation time via ts field in URL |
| Brute-force on HMAC | HMAC-SHA256 with full APP_ACCESS_REQUEST_HMAC_SECRET entropy; no feasible brute-force |
| Duplicate requests | Silent ignore; no information leak, no DB pollution |
Addendum (2026-09): the link names the request, not the address
The verification link shipped as /access-requests/verify?email=…&expires=…&signature=…,
signing email|expires. That put a plain address in a URL, which then sat in the Caddy and
Traefik access logs of every hop, in the Referer of the page's requests and in the browser
history. The link is now {FRONTEND_URL}/access-requests/verify#id=…&expires=…&signature=…:
- The HMAC covers
id|expires, whereidis theaccess_requestUUID. The id means nothing without the database, so the link carries no personal data, and the signature still proves the link came from us. There is still no token column. - The payload is in the fragment, which a browser never sends. The page reads it, erases it
from the address bar and
POSTs it as JSON to/access-requests/verify, which answers204for every outcome (no enumeration).GETis gone. - The edge case where a signed link outlived its row ("create a verified record directly") disappears: without the address there is nothing to create, and the row is only ever removed when the email could not be sent, so no such link was delivered.
Consequences
Positive
- Single table, no token columns — The
access_requesttable is append-only with only status transitions; no ephemeral token cleanup required. - Stateless HMAC verification — Approval tokens verified without a database lookup; scales naturally.
- No enumeration surface — Uniform responses across all submission outcomes.
- CLI-native workflow — Operator workflow requires no browser or admin UI; composable with scripts and cron jobs.
- Throttling is infrastructure-level — Redis RateLimiter operates before any business logic; spam is blocked early.
Negative
- No built-in token revocation — Once an HMAC approval link is generated, it is valid for 72 hours regardless of a subsequent
rejectCLI call. Mitigation: if a rejection occurs after approval, theaccess_requeststatus is set torejected, and the activation endpoint checks the database status before creating the user. - CLI-only operator interface — No web dashboard. Acceptable for a single-developer project; a future admin UI can be layered on top of the same
AccessRequestentity. - HMAC secret rotation invalidates all outstanding links — Rotating
APP_ACCESS_REQUEST_HMAC_SECRETforces re-approval of any pending activation links. Acceptable given the low volume of a beta program, and the scope of invalidation stays contained to early-access activation (no impact on CSRF tokens, remember-me cookies, or password resets signed withAPP_SECRET). - Extra environment variable to manage — Operators must provision and rotate
APP_ACCESS_REQUEST_HMAC_SECRETseparately fromAPP_SECRET. The variable is documented alongsideAPP_SECRETin.env.distand in the deployment runbook so the rotation procedure is discoverable.
Neutral
- No change to authentication flow — Once a user is created via the activation endpoint (or directly via CLI), authentication proceeds exactly as described in ADR-023 (magic link + JWT).
- IP address storage — The
ip_addresscolumn is stored for throttling audit purposes, not for profiling. Privacy implications are minimal; the data is visible only to the administrator via the CLI.