Postbox authenticates users through The Hub Bot. The login page links to the Hub Bot deep-link, and The Hub returns a signed handoff token to Postbox.
Telegram account (verified by The Hub Bot)
↓ (Hub Bot creates signed JWT)
The Hub Bot issues JWT with telegram_id
↓ (Postbox verifies Hub JWT signature)
Postbox extracts and validates telegram_id
↓ (local session creation)
Postbox application session (JWT in HttpOnly cookie)
↓ (authorization checks)
User data (ownership validation)
- Hub JWT verification — server validates HMAC signature of Hub token with shared secret
- Telegram identity — extracted from verified Hub JWT, not self-reported
- HTTPS only — all authentication happens over encrypted connections
- Application session — stateless JWT stored in secure HttpOnly cookie
- CSRF protection — double-submit cookie for state-changing requests
- Ownership checks — every query validates user owns the requested data
- User opens
/login - User clicks the Hub Bot link from
HUB_BOT_URL - The Hub Bot verifies the Telegram account
- The Hub Bot creates a signed JWT with user's
telegram_id - The Hub Bot generates auth URL:
/auth/hub?token=<JWT> - Postbox receives JWT and verifies signature with
HUB_AUTH_SECRET - Postbox extracts
telegram_idfrom verified token - Postbox creates local JWT session
- Signature verification: HS256 HMAC prevents tampering
- Shared secret:
HUB_AUTH_SECRETnever exposed to client - Short-lived: Hub JWT expires after 5 minutes (reduces replay window)
- Telegram-verified identity: Hub Bot already verified user via Telegram
- No dev bypass in production:
POSTBOX_DEV_LOGIN=falseenforced
Required environment variables:
HUB_AUTH_SECRET=<shared-secret-with-hub-bot># Must match The Hub Bot
HUB_BOT_URL=https://t.me/<hub-bot>?start=postbox
POSTBOX_JWT_SECRET_KEY=<strong-random-secret># For session JWTWhen POSTBOX_DEV_LOGIN=true, a test form accepts telegram_id + first_name without Hub Bot.
This must be false in production.
JWT contains only:
user_id— internal database IDtelegram_id— external stable identifieriat— issued at (Unix timestamp)exp— expires (Unix timestamp, default 365 days)
- Algorithm: HS256 (HMAC-SHA256)
- Secret:
POSTBOX_JWT_SECRET_KEY(generate withopenssl rand -hex 32) - Never stored in git
Token is stored only in cookie:
Set-Cookie: postbox_session=<jwt>; HttpOnly; // JavaScript cannot read
Secure; // HTTPS only (in production)
SameSite=Lax; // Some cross-site requests allowed
Path=/; // Available to all routes
Token is never stored in:
- localStorage
- sessionStorage
- URL parameters
- Request headers (except as-is from cookie)
On each authenticated request:
- Extract JWT from
postbox_sessioncookie - Verify signature with
POSTBOX_JWT_SECRET_KEY - Check
expis in future - Reject if invalid or expired
JWT is stateless — no revocation list. Tradeoff: cannot instantly logout without additional infrastructure.
For this personal application, the 365-day expiry + logout (cookie deletion) is acceptable.
Double-submit cookie pattern:
- Server generates
csrf_token = secrets.token_urlsafe(32) - Sends as
postbox_csrfcookie (HttpOnly, SameSite=Lax) - Form includes hidden
<input name="csrf_token" value="..."> - On POST, server compares form value to cookie value with
hmac.compare_digest()
All state-changing operations:
POST /mail(create)POST /mail/{id}/note(update note)POST /mail/{id}/received(mark received)POST /logout(logout)
_verify_csrf(request, csrf_token_from_form)Rejects if:
- Token missing from form
- Token missing from cookie
- Tokens don't match (constant-time comparison)
POST to /logout with valid CSRF token:
- Deletes
postbox_sessioncookie (Set-Cookie with Max-Age=0) - Deletes
postbox_csrfcookie - Redirects to
/login
Does not log user out of Telegram.
Every query validates the requesting user owns the data:
item=awaitMailItem.find_for_owner(
session,
owner_id=current_user_id,
mail_id=requested_mail_id
)
ifitemisNone:
raiseHTTPException(status_code=404)Returns 404 if:
- Mail item doesn't exist
- Mail item belongs to a different user
- Correspondent belongs to a different user
.gitignore:
.env
.env.*.local
*.key
secrets/
Provide secrets via environment variables (Docker secrets, CI/CD provider, etc):
export POSTBOX_JWT_SECRET_KEY="$(openssl rand -hex 32)"export HUB_AUTH_SECRET="<shared-secret-with-the-hub-bot>"export POSTBOX_PUBLIC_URL="https://postbox.example.com"Never log:
- JWT tokens (Postbox session JWT)
- Hub auth secret
- CSRF tokens
- Telegram auth payload (only log success/failure)
- Passwords (if added)
Security tests verify:
- ✅ Valid Telegram payload accepted
- ✅ Tampered payload rejected
- ✅ Stale auth_date rejected
- ✅ dev_hash rejected when not allowed
- ✅ JWT with wrong algorithm rejected
- ✅ Expired JWT rejected
- ✅ Tampered JWT rejected
- ✅ CSRF missing → request rejected
- ✅ CSRF mismatch → request rejected
- ✅ User A cannot access user B's data
Run all tests:
pytest tests/test_web.py -qAll authentication requires HTTPS. HTTP redirects to HTTPS.
Nginx should pass:
proxy_set_header Host $host;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme;App trusts X-Forwarded-Proto only from known reverse proxy (127.0.0.1 by default).
When multiple apps (Postbox, Traect, etc.) share The Hub:
- Single Telegram identity =
telegram_id - Each app has separate database and session cookie
- Compromise of one app session doesn't compromise others
No shared authentication database; each app is responsible for its authorization.
POSTBOX_DEV_LOGIN=falsein production configPOSTBOX_JWT_SECRET_KEYis strong random 64-char hex stringHUB_AUTH_SECRETmatches The Hub Bot configurationHUB_BOT_URLpoints to the Hub Bot Postbox entry point- HTTPS is enabled on reverse proxy
Secureflag in cookies istrue- Logs don't contain JWT, tokens, or Telegram payloads
- All tests pass
.envis in.gitignore(never committed)