Title :
Building a Home Secretary #5 - Server: Signatures and the Gatekeeper
This post is translated from the Korean original. Read in Korean →

In part 4, I settled the family, the devices, and permissions. Now I’m finally setting up Secretary’s gatekeeper.
I’ll write the code that creates and checks the HMAC signature on every request, open the first API that requests meet after passing that door, and finally assemble the app into one.
Step 1. Creating signatures (signing.py)
This is the signature I explained with a diagram in part 2. The server and clients have to create signatures in exactly the same way, so I gathered it into one function.
"""HMAC request signatures. The server and the example client use the same functions.
String to sign = METHOD \\n PATH[?QUERY] \\n TIMESTAMP \\n NONCE \\n SHA256(body) in hexSignature = base64url(HMAC-SHA256(secret, string to sign))"""import base64import hashlibimport hmacimport secretsimport time
HEADER_KEY = "X-Access-Key"HEADER_TIMESTAMP = "X-Timestamp"HEADER_NONCE = "X-Nonce"HEADER_SIGNATURE = "X-Signature"
def canonical_string(method: str, path: str, query: str, timestamp: str, nonce: str, body: bytes) -> str: """Build the string to sign. The server and client must build it identically, down to the last character, for signatures to match.
e.g. GET /v1/me, no body → GET /v1/me 1762300000 3f9c...(nonce) e3b0c442...(SHA-256 of an empty body) The body isn't included whole, only its hash. Change even one character of the body and the hash changes. """ target = f"{path}?{query}" if query else path body_hash = hashlib.sha256(body).hexdigest() return "\n".join([method.upper(), target, timestamp, nonce, body_hash])
def sign(secret: str, canonical: str) -> str: """Sign the string to sign with the Secret using HMAC-SHA256, returned as a base64url string.
base64url uses -, _ instead of +, / so it can go straight into an HTTP header, and the trailing = padding is stripped. """ digest = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).digest() return base64.urlsafe_b64encode(digest).decode().rstrip("=")
def build_headers(access_key: str, secret: str, method: str, path: str, query: str = "", body: bytes = b"") -> dict: """Build the 4 auth headers to attach to one request (for tests and the example client).
The timestamp is the current time (seconds), and the Nonce is a new random value every time, so even the same request gets a different signature each time. """ timestamp = str(int(time.time())) nonce = secrets.token_hex(16) canonical = canonical_string(method, path, query, timestamp, nonce, body) return { HEADER_KEY: access_key, HEADER_TIMESTAMP: timestamp, HEADER_NONCE: nonce, HEADER_SIGNATURE: sign(secret, canonical), }The string to sign contains five things.
- METHOD: GET or POST
- PATH (+query): Where it’s going
- TIMESTAMP: When it was sent
- NONCE: A one-time value created fresh every time
- SHA-256 hash of the body: What was sent
Because the body hash is included, if someone quietly changes the email body in transit, the signature won’t match. Because the path is included, you can’t peel the signature off a “mail request” and stick it on an “approve” request either.
Step 2. The gatekeeper (guard.py)
This is the heart of this part. Every request coming into /v1 has to pass this gatekeeper.
It plays the role of middleware in C# or Express servers, or a Filter or Interceptor in Java Spring.
"""Authentication dependency that runs in front of every /v1 request.
Check order: 1. Whether it came through Cloudflare Access (the JWT header cloudflared adds) 2. Header format, body size 3. Whether the key is locked out (keys with piling failures are blocked for a while) 4. Whether the device exists and isn't revoked, and whether the device owner (user) is active 5. Timestamp tolerance 6. Signature match (constant-time comparison) 7. Nonce reuse 8. scope (device scopes AND scopes the user's role allows)
The failure reason is recorded only in the audit log; the response is always the same single 401.Revealing where it was blocked would give attackers a hint."""import hmacimport reimport timefrom dataclasses import dataclass
from fastapi import HTTPException, Request, status
from .registry import Device, Userfrom .scopes import allows, effective_scopesfrom .signing import ( HEADER_KEY, HEADER_NONCE, HEADER_SIGNATURE, HEADER_TIMESTAMP, canonical_string, sign,)
_KEY_RE = re.compile(r"^[A-Za-z0-9_-]{3,64}$")_NONCE_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}$")_TS_RE = re.compile(r"^\d{9,11}$")
UNAUTHORIZED = HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Authentication failed.")FORBIDDEN = HTTPException(status.HTTP_403_FORBIDDEN, detail="Permission denied.")
@dataclassclass Caller: """A caller that passed authentication. Router functions receive this to know who (user), with which device (device), and what they can do (scopes)."""
user: User device: Device scopes: set[str] ip: str | None
def can(self, scope: str) -> bool: """Whether this caller can use a single scope. Wildcards (mail:*, modules:*) are checked too.""" return allows(self.scopes, scope)
def forbid(self, request: Request, scope: str) -> HTTPException: """Record the permission denial in the audit log and build the 403 exception to return. The caller raises it.""" request.app.state.audit.write( "auth.forbidden", user=self.user.id, device=self.device.access_key, ip=self.ip, scope=scope, path=request.url.path, ) return FORBIDDEN
def _client_ip(request: Request) -> str | None: """Find the requester's real IP. Used for the audit log.""" # For requests that came through Cloudflare, the real client IP is in this header. return request.headers.get("Cf-Connecting-Ip") or (request.client.host if request.client else None)
async def authenticate(request: Request) -> Caller: """Check the signature in the request headers and return the caller (Caller). 401 if anything is wrong.
See the description at the top of this file for the check order. Cheap checks (header format) come first, expensive checks (DB lookups, signature computation) come later. """ state = request.app.state settings = state.settings audit = state.audit ip = _client_ip(request)
def fail(reason: str, key: str | None = None) -> HTTPException: """Record an authentication failure in the audit log and the failure table, and build the 401 exception to return.
The caller uses it as raise fail("reason"). The reason goes only to the log, never into the response. Failure records are added only when the key format is valid (so the table can't be filled with junk values). """ audit.write("auth.fail", device=key, ip=ip, reason=reason, path=request.url.path) if key and _KEY_RE.match(key): with state.db.tx() as c: c.execute("INSERT INTO auth_failures (access_key, at) VALUES (?, ?)", (key, int(time.time()))) return UNAUTHORIZED
if settings.require_cf_access and not request.headers.get("Cf-Access-Jwt-Assertion"): raise fail("no_cf_access")
key = request.headers.get(HEADER_KEY, "") ts = request.headers.get(HEADER_TIMESTAMP, "") nonce = request.headers.get(HEADER_NONCE, "") signature = request.headers.get(HEADER_SIGNATURE, "") if not (_KEY_RE.match(key) and _TS_RE.match(ts) and _NONCE_RE.match(nonce) and signature): raise fail("bad_headers", key or None)
body = await request.body() if len(body) > settings.max_body_bytes: raise fail("body_too_large", key)
now = int(time.time()) with state.db.tx() as c: c.execute("DELETE FROM auth_failures WHERE at < ?", (now - settings.lockout_window_seconds,)) failures = c.execute( "SELECT COUNT(*) FROM auth_failures WHERE access_key = ? AND at > ?", (key, now - settings.lockout_window_seconds), ).fetchone()[0] if failures >= settings.lockout_threshold: audit.write("auth.locked", device=key, ip=ip, path=request.url.path) raise UNAUTHORIZED
device = state.registry.device(key) if device is None: raise fail("unknown_key", key) if device.revoked: raise fail("revoked_key", key) user = state.registry.user(device.user) if user is None or user.disabled: raise fail("user_disabled", key)
if abs(now - int(ts)) > settings.clock_skew_seconds: raise fail("timestamp_skew", key)
canonical = canonical_string(request.method, request.url.path, request.url.query, ts, nonce, body) expected = sign(device.secret, canonical) if not hmac.compare_digest(expected, signature): raise fail("bad_signature", key)
# Nonces are remembered for twice the timestamp tolerance. After that, the timestamp check catches them. with state.db.tx() as c: c.execute("DELETE FROM nonces WHERE expires_at < ?", (now,)) exists = c.execute( "SELECT 1 FROM nonces WHERE access_key = ? AND nonce = ?", (key, nonce) ).fetchone() if not exists: c.execute( "INSERT INTO nonces (access_key, nonce, expires_at) VALUES (?, ?, ?)", (key, nonce, now + settings.clock_skew_seconds * 2), ) if exists: raise fail("nonce_replay", key)
return Caller(user=user, device=device, scopes=effective_scopes(device.scopes, user.role), ip=ip)
def require(scope: str): """Take one scope and build and return a dependency function that "passes only with this permission."
@router.get("/me") async def me(caller: Caller = Depends(require(SYSTEM_READ))):
Authentication (401) comes first, then 403 if the scope is missing. It's a function that stamps out a check function per scope. """ async def dependency(request: Request) -> Caller: """The function FastAPI actually calls per request. Checks the scope after authentication.""" caller = await authenticate(request) if not caller.can(scope): raise caller.forbid(request, scope) return caller
return dependencyHere’s the check order as a diagram.
A few points worth noting.
- The failure reason never goes in the response. Whether the signature was wrong, the time expired, or the key was revoked, the response is the same single
401 Authentication failed.The real reason goes only into the audit log. It’s what I meant in the secure coding post by “error pages that say nothing.” Revealing where it was blocked gives attackers a hint. - Signatures are compared with
hmac.compare_digest. A plain==compares from the first character and stops the moment it hits a different one, so apparently a timing attack is possible, guessing the signature one character at a time from differences in response time. It’s an amazing attack technique I only just learned about too. It seems to take incredible patience, and at the same time it makes me think hacking techniques really are something else. - Nonces are remembered for only 120 seconds. That’s twice the timestamp tolerance (±60 seconds). Requests older than that get caught by the timestamp check anyway. So the Nonce table doesn’t grow endlessly.
- The device owner is checked too. Even if a device key is still alive, it’s blocked if that person has been disabled. And this is where the actual permissions (device scopes AND role ceiling) are calculated and stored in
Caller. In the APIs, you just ask something likecaller.can("mail:send")and it answers, wildcards included. - The Cloudflare Access header is checked too. cloudflared already does JWT verification, but even if someone pokes at it directly from inside the NAS network, it’s blocked without this header. Layer upon layer.

async and await?async def authenticate(...), body = await request.body(). There’s async before the function and await before the call.
These are markers that say “you can go do something else while waiting.” Time spent receiving a request body over the network, or waiting for an external API’s response, is time the CPU sits idle with nothing to do. When Python hits await, instead of sitting still until that work is done, it goes off to handle other requests in the meantime.
Let’s learn a bit more about async and await
import asyncioimport time
async def work(name: str, seconds: float) -> str: await asyncio.sleep(seconds) # Go do something else while waiting return f"{name} done"
async def main(): start = time.perf_counter() results = await asyncio.gather(work("mail", 1), work("note", 1), work("lights", 1)) print(results, f"{time.perf_counter() - start:.1f}s")
asyncio.run(main())$ python3 async_demo.py['mail done', 'note done', 'lights done'] 1.0sI ran three tasks that take 1 second each, and the total is 1 second. Waiting for them one at a time would have taken 3 seconds. That’s because all three waited at the same time.
There are just two rules to remember.
awaitcan only be used inside anasync deffunction, and anasync deffunction only gives you its result when called withawait. FastAPI runs functions defined withasync defthis way for you.
Step 3. The first API: Who am I? (routers/system.py)
Now that the gatekeeper is built, let’s open an API. The simplest one: “Who am I?”
from fastapi import APIRouter, Depends
from ..auth.guard import Caller, requirefrom ..auth.scopes import SYSTEM_READ
router = APIRouter(prefix="/v1", tags=["system"])
@router.get("/me")async def me(caller: Caller = Depends(require(SYSTEM_READ))): """GET /v1/me : Return whose device this key is, which device, and what it can do.
Meant to be the first call after setting up a new device. If you get a 200, both the signature and the Cloudflare Access setup are correct. """ d = caller.device return { "user": caller.user.public(), "device": {"access_key": d.access_key, "name": d.name}, "scopes": sorted(caller.scopes), }With just the one line Depends(require(SYSTEM_READ)), only requests that passed both the gatekeeper check and the scope check get into this function. The response shows who I am (the user), which device it is, and the actual permissions with the role ceiling applied.
The command API that calls modules (/v1/commands) and the approval APIs are built in part 8.
Depends?caller: Caller = Depends(require(SYSTEM_READ)). There’s a Depends(...) in the function argument position.
Depends is FastAPI’s dependency injection. It means “before running this API, run this function first and pass its result in as the argument.” If the function that runs first throws an error, the API function never runs at all.
from fastapi import Depends, FastAPI, Header, HTTPExceptionfrom fastapi.testclient import TestClient
app = FastAPI()
def who_are_you(x_name: str = Header("")) -> str: if not x_name: raise HTTPException(401, "Who are you?") return x_name
@app.get("/hello")def hello(name: str = Depends(who_are_you)): return {"hello": name}
client = TestClient(app)print(client.get("/hello").status_code)print(client.get("/hello", headers={"X-Name": "dad"}).json())$ python3 depends_demo.py401{'hello': 'dad'}Without the
X-Nameheader,who_are_youthrows a 401 andhellodoesn’t even run. With it, that value comes in asname. In Secretary,require(...)plays this role. Only after passing the gatekeeper check and the scope check does aCallercome into the API function. So there’s no need to copy and paste auth code into every API. In Spring terms, it feels like a Security filter and@AuthenticationPrincipalcombined.
Step 4. Assembling the app (main.py)
import loggingimport uuidfrom contextlib import asynccontextmanager
from fastapi import FastAPI, Requestfrom fastapi.exceptions import RequestValidationErrorfrom fastapi.responses import JSONResponsefrom starlette.exceptions import HTTPException
from .audit import AuditLogfrom .auth.registry import Registryfrom .config import Settings, get_settingsfrom .db import Databasefrom .approvals import ApprovalQueuefrom .catalog import ModuleCatalogfrom .events import EventStorefrom .routers import approvals, commands, events, system
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")logger = logging.getLogger("secretary")
def create_app(settings: Settings | None = None, module_transport=None) -> FastAPI: """Create the FastAPI app. Tests create their own by passing in settings and a fake module (module_transport).
The production server has uvicorn load app = create_app() at the very bottom. """ settings = settings or get_settings()
@asynccontextmanager async def lifespan(app: FastAPI): """When the server starts (before yield), create the needed objects and put them on app.state; when it stops (after yield), clean up.
Routers fetch them like request.app.state.db. """ app.state.settings = settings app.state.db = Database(settings.db_path) app.state.registry = Registry(settings.registry_path, settings.max_users) app.state.audit = AuditLog(settings.audit_path) app.state.approvals = ApprovalQueue(app.state.db, settings.approval_ttl_seconds) app.state.events = EventStore(app.state.db) app.state.catalog = ModuleCatalog(settings.module_endpoints, settings.module_timeout_seconds, transport=module_transport) app.state.audit.write("server.start", modules=sorted(settings.module_endpoints)) yield await app.state.catalog.close() app.state.db.close()
# Don't open the docs pages in production. If you need the spec, generate it locally. app = FastAPI(title="Secretary", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
@app.middleware("http") async def request_id(request: Request, call_next): """Middleware that attaches X-Request-Id to every response.
If an unexpected error occurs, the details go only to the server log, and the client gets just a 500 with the same ID. If a user tells you the ID, you can find that request in the log right away. """ rid = uuid.uuid4().hex[:12] try: response = await call_next(request) except Exception: logger.exception("unhandled error rid=%s path=%s", rid, request.url.path) response = JSONResponse({"code": "INTERNAL_ERROR", "message": "A temporary error occurred."}, 500) response.headers["X-Request-Id"] = rid return response
@app.exception_handler(HTTPException) async def http_error(request: Request, exc: HTTPException): """Unify HTTP errors (401, 403, 404 ...) into JSON shaped like {code, message}.""" return JSONResponse({"code": f"HTTP_{exc.status_code}", "message": exc.detail}, exc.status_code)
@app.exception_handler(RequestValidationError) async def validation_error(request: Request, exc: RequestValidationError): """Turn request format errors (422) into the shape {code, message, fields}.""" # Only say which fields were wrong and why; never send back the input values themselves. fields = [".".join(str(p) for p in e["loc"][1:]) for e in exc.errors()] return JSONResponse({"code": "INVALID_REQUEST", "message": "Invalid request format.", "fields": fields}, 422)
@app.get("/healthz", include_in_schema=False) async def healthz(): """GET /healthz : Whether the server is alive. Open without auth, called by the container health check.""" return {"ok": True}
app.include_router(system.router) app.include_router(commands.router) app.include_router(events.router) app.include_router(events.internal) app.include_router(approvals.router) return app
app = create_app()I put everything I talked about in the secure coding post in here.
- Turn off the API docs pages. By default, FastAPI serves a Swagger page at
/docs. It’s convenient, but on a server visible from outside, it’s like handing attackers a map.docs_url=None,openapi_url=None. - Unify the error response format. Whatever the error, it goes out as a single
{"code": "...", "message": "..."}. For unexpected exceptions, the stack trace goes only to the server log, and the response says only “A temporary error occurred.” - Don’t send input values back, even in validation errors. Only say which fields were wrong.
- Attach
X-Request-Idto every request. When something goes wrong, use this ID to find it in the server log right away.
@asynccontextmanager and lifespan?The lifespan function near the top of main.py. It’s the async version of the @contextmanager we saw in part 3. It looks the same too, with yield in the middle.
In FastAPI, this is used for the server’s lifetime. Everything before yield runs once when the server starts, and everything after yield runs once when the server stops. The time in between is when the server is taking requests.
from contextlib import asynccontextmanager
from fastapi import FastAPIfrom fastapi.testclient import TestClient
@asynccontextmanagerasync def lifespan(app: FastAPI): print("Server start: connect DB") yield print("Server stop: close DB")
app = FastAPI(lifespan=lifespan)
@app.get("/")def root(): print("Handling request") return {}
with TestClient(app) as client: client.get("/")$ python3 lifespan_demo.pyServer start: connect DBHandling requestServer stop: close DBWhen the server starts, Secretary creates the DB connection, the user/device store, the audit log, the approval queue, the event store, and the module catalog (the module list and the HTTP client for calling modules) once each, puts them on
app.state, and pulls them out for each request. When it stops, it closes the HTTP connections and the DB connection. It’s like Spring’s@PostConstructand@PreDestroyrolled into one function.
To sum up
- Authentication: Per-device keys + an HMAC signature on every request + timestamp + Nonce + failure lockout. Failure reasons go only to the log
- First API:
GET /v1/me. If the signature is valid, it returns who I am and my actual permissions - Error responses: No matter what happens, only in a fixed format, with the cause only in the server log
Now the hub is running, letting in only requests with valid signatures. But there’s no registered family yet. In part 6, I’ll build the CLI for registering the family, test whether the door really blocks things, and call it myself with the example client.
Bye for now!

Loading comments...