VaultPay is a wallet microservice I built on top of AuthShield. Previous parts: Part 1 is here: I Built AuthShield and Immediately Knew It Wasn't Enough Part 2 is here: The Silent Failure I Never Saw Coming: What VaultPay Taught Me About Consistency Under Failure

When I started thinking about IP-based security for VaultPay, my first instinct was a blocklist. Maintain a list of known bad IPs, check every request against it, reject anything that matches. Simple. Obvious. Wrong.

A blocklist tells you about IPs you already know are bad. It has nothing to say about an IP you've never seen before — which is exactly the scenario that matters in a financial system. A new IP showing up on a known account isn't necessarily malicious. But it's also not something you should silently allow through to a transaction endpoint.

The question I kept coming back to wasn't "how do I block bad IPs." It was "how do I handle IPs I don't know yet — without making the experience painful for legitimate users who switch networks, travel, or use a VPN?"

That tension is what the IP trust system in VaultPay is designed to navigate.

Why Middleware, Not the Endpoint

Before getting into how the system works, it's worth explaining where it lives.

The IP trust check in VaultPay runs at the middleware level — before any endpoint handler executes. This was a deliberate architectural decision, and it changes everything about how the system behaves.

If the check lived inside each endpoint, you'd have to remember to call it in every handler that needed it. You'd miss one. Or you'd add a new endpoint and forget to wire it in. Or a future developer would copy an endpoint without understanding why that check was there.

Middleware doesn't have that problem. The check runs once, on every request, before the request reaches the router. If the IP isn't trusted, the request never gets to the wallet logic, the transaction logic, the PIN verification — any of it. The system never has to think about untrusted IPs inside its business logic because untrusted IPs don't survive long enough to get there.

Request arrives
    ↓
[IP Trust Middleware]  ← runs here, before anything else
    ↓ trusted
JWT Validation
    ↓
PIN Lockout Check
    ↓
Endpoint Handler
    ↓
Atomic Transfer

This is the reason the IP trust check doesn't appear in the send money sequence diagram. It's invisible to the feature flow because a request from an untrusted IP never reaches the feature flow.

What "Trust" Actually Means

An IP being trusted doesn't mean it's safe. It means VaultPay has seen it before and the account owner has confirmed it.

The trust model works like this:

Every IP that successfully completes a request gets stored in a known_ips cache keyed by user. When a request arrives, middleware checks the sender's IP against this cache. If the IP is there - request proceeds normally. If the IP isn't there - the request gets blocked and a 30-minute confirmation window opens.

During that window, VaultPay sends an email to the account's registered address. The email contains a confirmation link. Until the user clicks that link, requests from that IP are blocked. After confirmation, the IP is added to the trusted cache and future requests from it proceed without interruption.

This is meaningfully different from just blocking new IPs forever. A hard block would be the right security decision and a terrible user experience decision simultaneously. Someone logging in from a new device, a hotel WiFi, a different city — all of these are legitimate scenarios. The 30-minute hold gives the system time to verify intent without permanently locking out real users.

Why Redis TTLs Replaced What I Thought Needed a Cron Job

When I first sketched out the IP trust system, I assumed I'd need some kind of scheduled job to manage stale data. Trusted IPs don't stay relevant forever. A user changes their home network. They stop using a particular device. The IP they confirmed six months ago is irrelevant now.

My initial thinking was: run a cron job every night, scan the trusted IP table, delete entries older than some threshold.

The problem with that approach is the threshold is arbitrary. Thirty days? Ninety days? And the cleanup is periodic — meaning between runs, your trusted IP table is accumulating entries that no longer reflect reality.

Redis TTLs handle this more cleanly. Every trusted IP entry is stored with a time-to-live. When the TTL expires, Redis deletes the key automatically — no cron job, no scheduled cleanup, no arbitrary threshold decision baked into application code. The expiry is part of the data itself.

async def trust_ip(user_id: UUID, ip_address: str, redis: Redis):
    key = f"vp:ip:trusted:{user_id}:{ip_address}"
    # Store with TTL — Redis handles expiry automatically
    await redis.setex(key, settings.IP_TRUST_TTL_SECONDS, "1")

async def is_ip_trusted(user_id: UUID, ip_address: str, redis: Redis) -> bool:
    key = f"vp:ip:trusted:{user_id}:{ip_address}"
    return await redis.exists(key) == 1

The check itself is a single Redis EXISTS call. Sub-millisecond. It adds effectively zero latency to every request, which matters because this runs on every single request across the entire API.

The tradeoff is that TTL-based trust is periodic, not event-driven. If a user's account is compromised and you want to immediately revoke all trusted IPs, you need to scan and delete all keys matching vp:ip:trusted:{user_id}:*. That's a pattern scan rather than a point lookup - slightly more expensive, but it's an admin action that happens rarely, not a per-request operation.

The Confirmation Flow

When a new IP is detected, the flow branches away from the normal request path.

VaultPay generates a confirmation token, stores it in Redis with a 30-minute TTL keyed to the pending IP, and sends an email to the account owner. The original request gets a 403 NEW_IP_DETECTED response with a message explaining what happened.

async def handle_new_ip(user_id: UUID, ip_address: str, redis: Redis):
    # Check if already pending confirmation
    pending_key = f"vp:ip:pending:{user_id}:{ip_address}"
    already_pending = await redis.exists(pending_key)

    if not already_pending:
        # Generate confirmation token
        token = secrets.token_urlsafe(32)
        await redis.setex(pending_key, 1800, token)  # 30 minutes

        # Trigger email — async, doesn't block the response
        await send_ip_confirmation_email(user_id, ip_address, token)

    raise NewIPDetectedError(
        message="Request from unrecognised IP. Check your email to confirm."
    )

The already_pending check matters. Without it, every retry from the same unconfirmed IP would trigger a new email. A user who's confused about what happened and hits the endpoint three times ends up with three emails and three different confirmation tokens - only the last one works. The pending key prevents that. If confirmation is already in progress, VaultPay just raises the same error without sending another email.

When the user clicks the confirmation link:

async def confirm_ip(user_id: UUID, ip_address: str, token: str, redis: Redis):
    pending_key = f"vp:ip:pending:{user_id}:{ip_address}"

    stored_token = await redis.get(pending_key)
    if not stored_token or stored_token != token:
        raise InvalidOrExpiredTokenError()

    # Token valid — promote IP to trusted
    await trust_ip(user_id, ip_address, redis)
    await redis.delete(pending_key)

Token validation, promotion to trusted, cleanup. Three operations. The pending key gets deleted so it can't be reused. The trusted key gets created with its own TTL so it auto-expires on its own schedule.

What Happens to the Original Request

One thing I had to think through: the user confirmed their IP, but the original request they were trying to make is gone. They got a 403, had to check their email, clicked a link — and now they're back at the same endpoint needing to retry.

VaultPay doesn't replay the original request automatically. The client is responsible for retrying after confirmation. This was the right call for a financial system — automatically replaying a transaction that was blocked mid-way creates its own consistency problems. The idempotency key on the send money endpoint handles retries cleanly, but the retry itself has to come from the client.

The 403 response body includes enough context for the client to know exactly what to tell the user: "Your request was blocked because we didn't recognise this device. We've sent a confirmation email. Once confirmed, please try again."

Two-Layer Rate Limiting

IP trust handles the "is this device known" question. But it's one layer of a two-layer system.

VaultPay also enforces per-user, per-endpoint rate limits in Redis-completely separate from the IP trust check. Send money is capped at 20 requests per minute per user. Top-up is capped at 10 per minute. These limits use a sliding window counter that increments on each request and expires after the window closes.

The two layers address different threats. IP trust addresses account takeover from an unknown device. Rate limiting addresses automated abuse from any device, trusted or not. A trusted IP can still hit the rate limit. An untrusted IP never makes it to the rate limiter.

Untrusted IP  →  [IP Trust Middleware]  →  403 NEW_IP_DETECTED
                         (stops here)

Trusted IP    →  [IP Trust Middleware]  →  pass
              →  [Rate Limit Check]     →  429 if over limit
              →  Endpoint Handler

Nginx adds a third layer above both - a global rate limit per IP at the network level, before the request even hits the application. That one lives outside VaultPay entirely and catches volumetric attacks that the application-level checks aren't designed for.

What I Actually Learned

The instinct to build a blocklist was wrong not because blocklists are useless but because I was thinking about the wrong problem. A blocklist is reactive - it responds to IPs you already know are bad. The real problem in a financial system is handling IPs you've never seen before, which are the norm for legitimate users as much as attackers.

The insight that shifted my thinking was this: security and user experience are usually in tension, but they don't have to be. The 30-minute hold with email confirmation isn't a compromise between the two. It's a design that serves both - the legitimate user gets a path to confirm their device, and the attacker with stolen credentials but no email access gets permanently blocked.

And Redis TTLs taught me something broader about state management. Anything with a natural expiry - trust windows, pending confirmations, rate limit counters - belongs in Redis with a TTL, not in a database with a cron job. The expiry is a property of the data. It should live with the data.

Next up: a problem that's easy to underestimate until you see it in production - what happens when the exact same transfer request arrives twice, and how VaultPay knows it's already been processed.

Engineering docs + code samples: Vaultpay-Engineering