PIN Gate - NGINX Front-Door Authentication¶
Project Overview¶
A minimal PIN pad authentication gate for an NGINX reverse proxy. Once authenticated via a simple numeric keypad, users gain access to all subdomains on the nominate.ai domain via a secure, domain-wide cookie.
Use Case¶
- Client navigates to
https://testsite.nominate.ai - NGINX intercepts, checks for valid session cookie
- No valid session → redirect to non-descript PIN pad
- Correct PIN → cookie set on
.nominate.ai, redirect back to original URL - All
*.nominate.aisubdomains now accessible for session duration
Architecture¶
┌─────────────────────────────────────────────────────────────────┐
│ Client Request │
│ https://testsite.nominate.ai/dashboard │
└─────────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ NGINX (bare-metal) │
│ │
│ auth_request ──► /internal/auth/verify │
│ │ │
│ ▼ │
│ ┌─────────┐ YES ┌─────────────┐ │
│ │ Valid │──────►│ Proxy to │ │
│ │ cookie? │ │ backend │ │
│ └─────────┘ └─────────────┘ │
│ │ NO │
│ ▼ │
│ ┌─────────────┐ │
│ │ 302 to │ │
│ │ /auth/pin │ │
│ └─────────────┘ │
└─────────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ PIN Gate Auth Service (FastAPI) │
│ Upstream: 127.0.0.1:8901 │
│ │
│ GET /auth/verify - Validate session cookie (200/401) │
│ GET /auth/pin - Serve PIN pad HTML │
│ POST /auth/login - Validate PIN, set cookie, redirect │
│ GET /auth/logout - Clear cookie │
│ GET /health - Health check │
└─────────────────────────────────────────────────────────────────┘
Constraints¶
- Not application layer - Auth happens at NGINX, transparent to backends
- NGINX on bare-metal - No Docker, systemd service
- Domain-wide cookie - Single auth grants access to all
*.nominate.ai - Minimal UI - Non-descript number pad, no branding
- Stepping stone - Architecture supports evolution to full auth server
Technical Specification¶
Directory Structure¶
pin_gate/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI application entry
│ ├── config.py # Pydantic Settings
│ ├── models.py # Pydantic models
│ ├── auth.py # Token creation/verification logic
│ ├── routes/
│ │ ├── __init__.py
│ │ └── auth.py # Auth route handlers
│ └── templates/
│ └── pin_pad.html # PIN pad template
├── nginx/
│ ├── pin-gate.conf # Main NGINX config
│ └── snippets/
│ └── pin-gate-auth.conf # Includable snippet
├── systemd/
│ └── pin-gate.service # Systemd unit file
├── requirements.txt
├── .env.example
└── README.md
Pydantic Models¶
Settings (config.py)¶
from pydantic_settings import BaseSettings
from pydantic import Field, field_validator
import secrets
class Settings(BaseSettings):
"""Application settings loaded from environment."""
# PIN Configuration
pin_hash: str = Field(
...,
description="SHA256 hash of the PIN"
)
# Security
secret_key: str = Field(
default_factory=lambda: secrets.token_hex(32),
description="Secret key for signing session tokens"
)
# Cookie Configuration
cookie_name: str = Field(
default="pin_gate_session",
description="Name of the session cookie"
)
cookie_domain: str = Field(
default=".nominate.ai",
description="Cookie domain (leading dot for subdomains)"
)
session_ttl: int = Field(
default=604800,
ge=300,
le=2592000,
description="Session TTL in seconds (default 7 days)"
)
# Server
host: str = Field(default="127.0.0.1")
port: int = Field(default=8901)
model_config = {
"env_prefix": "PIN_GATE_",
"env_file": ".env"
}
Request/Response Models (models.py)¶
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
class TokenPayload(BaseModel):
"""JWT-like token payload."""
exp: int = Field(..., description="Expiration timestamp")
iat: int = Field(..., description="Issued at timestamp")
jti: str = Field(..., description="Unique token ID")
ip: Optional[str] = Field(None, description="Client IP (optional binding)")
class AuthVerifyResponse(BaseModel):
"""Response for /auth/verify endpoint."""
authenticated: bool
class LoginRequest(BaseModel):
"""PIN submission."""
pin: str = Field(..., min_length=4, max_length=8, pattern=r"^\d+$")
class HealthResponse(BaseModel):
"""Health check response."""
status: str = "ok"
timestamp: datetime
Auth Logic (auth.py)¶
import hashlib
import hmac
import time
import json
import base64
import secrets
from .models import TokenPayload
from .config import Settings
def hash_pin(pin: str) -> str:
"""SHA256 hash a PIN."""
return hashlib.sha256(pin.encode()).hexdigest()
def verify_pin(pin: str, pin_hash: str) -> bool:
"""Constant-time PIN verification."""
return hmac.compare_digest(hash_pin(pin), pin_hash)
def create_session_token(settings: Settings, client_ip: str) -> str:
"""Create a signed session token."""
payload = TokenPayload(
exp=int(time.time()) + settings.session_ttl,
iat=int(time.time()),
jti=secrets.token_hex(8),
ip=client_ip
)
payload_b64 = base64.urlsafe_b64encode(
payload.model_dump_json().encode()
).decode()
signature = hmac.new(
settings.secret_key.encode(),
payload_b64.encode(),
hashlib.sha256
).hexdigest()
return f"{payload_b64}.{signature}"
def verify_session_token(token: str, settings: Settings) -> bool:
"""Verify token signature and expiration."""
try:
payload_b64, signature = token.rsplit(".", 1)
expected_sig = hmac.new(
settings.secret_key.encode(),
payload_b64.encode(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected_sig):
return False
payload_json = base64.urlsafe_b64decode(payload_b64).decode()
payload = TokenPayload.model_validate_json(payload_json)
if payload.exp < time.time():
return False
return True
except Exception:
return False
Route Handlers (routes/auth.py)¶
from fastapi import APIRouter, Request, Response, Form, Cookie, Depends
from fastapi.responses import HTMLResponse, RedirectResponse
from typing import Optional, Annotated
from ..config import Settings
from ..auth import verify_pin, verify_session_token, create_session_token
router = APIRouter(prefix="/auth", tags=["auth"])
def get_settings() -> Settings:
return Settings()
def get_client_ip(request: Request) -> str:
"""Extract client IP from X-Forwarded-For or direct connection."""
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host
@router.get("/verify")
async def verify_auth(
request: Request,
settings: Annotated[Settings, Depends(get_settings)],
pin_gate_session: Annotated[Optional[str], Cookie()] = None
) -> Response:
"""
NGINX auth_request endpoint.
Returns 200 if authenticated, 401 if not.
"""
if pin_gate_session and verify_session_token(pin_gate_session, settings):
return Response(status_code=200)
return Response(status_code=401)
@router.post("/login")
async def login(
request: Request,
settings: Annotated[Settings, Depends(get_settings)],
pin: Annotated[str, Form()]
) -> Response:
"""Validate PIN and set session cookie."""
client_ip = get_client_ip(request)
original_uri = request.headers.get("X-Original-URI", "/")
original_host = request.headers.get("X-Original-Host", "nominate.ai")
if verify_pin(pin, settings.pin_hash):
token = create_session_token(settings, client_ip)
redirect_url = f"https://{original_host}{original_uri}"
response = RedirectResponse(url=redirect_url, status_code=303)
response.set_cookie(
key=settings.cookie_name,
value=token,
domain=settings.cookie_domain,
max_age=settings.session_ttl,
httponly=True,
secure=True,
samesite="lax"
)
return response
return RedirectResponse(url="/auth/pin?error=1", status_code=303)
@router.get("/pin", response_class=HTMLResponse)
async def pin_page(request: Request, error: Optional[str] = None) -> str:
"""Serve the PIN pad page."""
# Return PIN pad HTML (see template below)
pass
@router.get("/logout")
async def logout(settings: Annotated[Settings, Depends(get_settings)]) -> Response:
"""Clear session cookie."""
response = RedirectResponse(url="/auth/pin", status_code=303)
response.delete_cookie(key=settings.cookie_name, domain=settings.cookie_domain)
return response
PIN Pad UI (templates/pin_pad.html)¶
Minimal, non-descript numeric keypad:
- Dark theme (
#1a1a2ebackground) - Circular buttons in 3x4 grid
- Dot indicators for entered digits (no actual numbers shown)
- Touch-friendly, mobile-responsive
- Keyboard support (0-9, Enter, Backspace)
- No branding, no hints about what's behind it
- Subtle error shake animation on invalid PIN
Key CSS properties:
- -webkit-tap-highlight-color: transparent for mobile
- user-scalable=no in viewport
- Button active state with accent color (#e94560)
NGINX Configuration¶
Main Config (nginx/pin-gate.conf)¶
# Upstream for PIN gate auth service
upstream pin_gate_auth {
server 127.0.0.1:8901;
keepalive 32;
}
# Protected site example
server {
listen 443 ssl http2;
server_name testsite.nominate.ai;
ssl_certificate /etc/nginx/ssl/nominate.ai/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/nominate.ai/privkey.pem;
# Internal auth verification
location = /internal/auth/verify {
internal;
proxy_pass http://pin_gate_auth/auth/verify;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Cookie $http_cookie;
}
# Auth endpoints (no auth_request - would cause loop)
location /auth/ {
proxy_pass http://pin_gate_auth;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-Host $host;
}
# Protected content
location / {
auth_request /internal/auth/verify;
error_page 401 = @pin_redirect;
proxy_pass http://127.0.0.1:8000; # Your backend
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location @pin_redirect {
return 302 /auth/pin?next=$request_uri;
}
}
Reusable Snippet (nginx/snippets/pin-gate-auth.conf)¶
For including in existing server blocks:
location = /internal/auth/verify {
internal;
proxy_pass http://pin_gate_auth/auth/verify;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Cookie $http_cookie;
}
location /auth/ {
proxy_pass http://pin_gate_auth;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Original-URI $request_uri;
proxy_set_header X-Original-Host $host;
}
location @pin_redirect {
return 302 /auth/pin;
}
Systemd Service (systemd/pin-gate.service)¶
[Unit]
Description=PIN Gate Auth Service
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/pin-gate
Environment="PATH=/opt/pin-gate/venv/bin"
EnvironmentFile=/opt/pin-gate/.env
ExecStart=/opt/pin-gate/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8901
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Deployment¶
Installation Steps¶
# 1. Create directory and venv
sudo mkdir -p /opt/pin-gate
sudo python3 -m venv /opt/pin-gate/venv
sudo /opt/pin-gate/venv/bin/pip install -r requirements.txt
# 2. Copy application files
sudo cp -r app /opt/pin-gate/
# 3. Create .env with hashed PIN
echo -n "YOUR_PIN" | sha256sum | cut -d' ' -f1
# Copy hash to .env as PIN_GATE_PIN_HASH
# 4. Set permissions
sudo chown -R www-data:www-data /opt/pin-gate
# 5. Install systemd service
sudo cp systemd/pin-gate.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now pin-gate
# 6. Configure NGINX
sudo cp nginx/snippets/pin-gate-auth.conf /etc/nginx/snippets/
# Add upstream and include snippet in your server blocks
sudo nginx -t && sudo systemctl reload nginx
Environment File (.env)¶
PIN_GATE_PIN_HASH=your_sha256_hash_here
PIN_GATE_SECRET_KEY=your_random_secret_here
PIN_GATE_COOKIE_DOMAIN=.nominate.ai
PIN_GATE_SESSION_TTL=604800
Generate values:
# PIN hash
echo -n "123456" | sha256sum | cut -d' ' -f1
# Secret key
python3 -c "import secrets; print(secrets.token_hex(32))"
Security Considerations¶
- HTTPS Required - Cookie has
Secureflag - HttpOnly Cookie - Not accessible via JavaScript
- Constant-time Comparison - Prevents timing attacks
- Signed Tokens - HMAC-SHA256, cannot be forged
- Rate Limiting - Add NGINX
limit_reqon/auth/login:
limit_req_zone $binary_remote_addr zone=pin_limit:10m rate=5r/m;
location /auth/login {
limit_req zone=pin_limit burst=3 nodelay;
proxy_pass http://pin_gate_auth;
}
Future Evolution Path¶
This architecture supports gradual evolution to a full auth server:
- Add user database - Replace single PIN with user accounts
- Add OAuth2/OIDC - Standard protocol support
- Add 2FA - TOTP, WebAuthn
- Add session management - Active session listing, revocation
- Add audit logging - Authentication events
The auth_request pattern and cookie-based sessions remain unchanged.
Requirements¶
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
pydantic>=2.5.0
pydantic-settings>=2.1.0
python-multipart>=0.0.6