← Back to projects

Full-Stack / IoT · 2026

NFC cloud portal shipped

Smart product redirector system

< 200ms
Redirect latency
100%
Uptime (Render)
HMAC + CSP
Security hardened
Real-time
Tap analytics

Physical products need a bridge to digital experiences. NFC-embedded items require a cloud backend that can dynamically route taps to custom URLs, track analytics, and provide owners with a management dashboard.

Engineered an end-to-end NFC product platform: hardware programming, cloud redirector service, admin dashboard, and analytics pipeline. Each NFC chip is programmed with a unique ID that resolves through the cloud portal to a configurable destination.

How it's wired

NFC tap resolves through a stateless redirector. HMAC + rate-limiting block ID enumeration on the edge. Admin console sits behind an OIDC identity provider with MFA, short-lived tokens, and per-request token verification — no single admin password is ever the blast radius. Coral arrows = failure paths.

PHYSICAL AUTH GATE EDGE STATE NFC chip NTAG215 · pre-programmed URL + HMAC token User device iOS / Android tap HMAC verify Per-chip signature • Rate limit / chip-ID • CSP headers • No ID enumeration • Audit log on fail Flask redirector Stateless · on Render In-memory LRU cache Fail-soft on DB blip Spoof / enum attempts Invalid HMAC → 403 + log Neon Postgres chip_id → URL Indexed lookup Owner table Analytics fan-out Geo · time · device CONTROL PLANE Auth0 (OIDC + MFA) Identity provider • MFA enforced on admin role • 15-min short-lived access tokens • Refresh-token rotation on use • Hijacked session → revoke + reauth Admin console Token verified per request (JWKS) • Owner-claim scopes endpoints • Audit log on every write • HMAC secrets in env · rotatable • Per-operator identity — no shared key Hardened admin plane No shared password · MFA + audit per operator · keys rotatable

Technical decisions

Why this stack, what the trade-offs were.

How is NFC ID enumeration prevented?

The /resolve endpoint only returns redirect data when the request includes a valid HMAC signature derived from a server-side secret. Forging signatures requires the secret; without it, incrementing IDs in the URL gets you 403s. Per-chip rate-limiting blocks brute-force enumeration of valid IDs. Admin endpoints (where the real data lives) require JWT auth scoped to chip ownership — even if you found a valid ID, you can't read its analytics or change its destination unless you own it.

Why a stateless redirector instead of programming URLs directly on the chip?

Chips are write-once in production. A stateless redirector means destination URLs can change after the product ships — same physical chip, new campaign. The chip stores a permanent ID + signed token; the backend owns the routing.

Why an in-memory LRU cache instead of just hitting the DB every time?

Fail-soft over fail-closed. If Neon has a 30-second blip, the redirector still serves the last-known destination from cache rather than 404'ing every tap. Cache TTL is short (60s) so legitimate admin changes still propagate quickly. Trade-off: a malicious admin change is visible for up to 60 seconds.

What protects the admin console if a session is hijacked?

The admin plane sits behind an OIDC identity provider (Auth0) with MFA enforced on the admin role. Access tokens are short-lived (15 minutes) and verified per request against the provider's JWKS endpoint, so a stolen cookie expires fast. Refresh tokens rotate on every use — replay of an old refresh token invalidates the chain and forces reauth. There is no single shared admin password: each operator authenticates as themselves, every write is audit-logged with the operator's user ID, and HMAC signing secrets live in environment config (rotatable) rather than the user-facing auth surface. Compromising one operator's account doesn't expose the chip-signing keys; compromising the keys requires server access, not a phishing email.

Why an external IdP instead of rolling auth in Flask?

Hand-rolled auth is where most small projects die: password storage, session invalidation, MFA enrollment, account recovery, lockout — each one is a security paper waiting to happen. Auth0 / Clerk / Cognito hand you OIDC + MFA + audit trails out of the box for the price of a small monthly bill. The trade-off is vendor lock-in on the auth surface, which I accept because the upside (correct primitives) dwarfs the risk (migrating IdPs is a one-week project, not a rewrite).

Why a single admin key today instead of per-user JWT?

Honest answer: it's a single-operator product right now. Adding multi-user OIDC + role management before there's a second user is over-engineering. The diagram shows where the IdP slots in — designed for that path, not built speculatively. The HMAC-signing secret for chips is already separated from admin auth, so the migration is auth-only and doesn't touch the redirect plane.

Edge case handling

What breaks at the edges, and how the system responds.

Limitations

What this system is not today — to be precise about scope.

What breaks first at 10x

Stateless Flask service sits behind Render's load balancer — horizontal scale is a config change. Neon Postgres autoscales reads. Next bottleneck is analytics writes; that moves to an async queue (Redis stream → batched insert) before becoming a problem.

What I'd build next

Promote admin auth from single key to Auth0 OIDC with MFA on the admin role, short-lived access tokens, and JWT-with-owner-claims so the system supports a marketplace of plate owners. Move analytics writes to an async Redis stream so write volume doesn't hit redirect latency. Add scheduled redirect changes for campaign-style use.

Build details

Python Flask Neon Postgres JavaScript NFC Render

Want to dig deeper?

Happy to walk through code, decisions, or design files.

Get in touch →