Skip to content

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

  1. Client navigates to https://testsite.nominate.ai
  2. NGINX intercepts, checks for valid session cookie
  3. No valid session → redirect to non-descript PIN pad
  4. Correct PIN → cookie set on .nominate.ai, redirect back to original URL
  5. All *.nominate.ai subdomains 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 (#1a1a2e background)
  • 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

  1. HTTPS Required - Cookie has Secure flag
  2. HttpOnly Cookie - Not accessible via JavaScript
  3. Constant-time Comparison - Prevents timing attacks
  4. Signed Tokens - HMAC-SHA256, cannot be forged
  5. Rate Limiting - Add NGINX limit_req on /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:

  1. Add user database - Replace single PIN with user accounts
  2. Add OAuth2/OIDC - Standard protocol support
  3. Add 2FA - TOTP, WebAuthn
  4. Add session management - Active session listing, revocation
  5. 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

Testing

# Health check
curl http://localhost:8901/health

# Verify endpoint (should return 401)
curl -v http://localhost:8901/auth/verify

# PIN page
curl http://localhost:8901/auth/pin

# Login (will set cookie on success)
curl -X POST -d "pin=123456" http://localhost:8901/auth/login