The four-minute approval
In the previous post we said: “the cryptography did the work that SSO + 2FA used to do.” Several engineers wrote in asking what that actually meant in code. This post is the answer.
The starting point was a measurable friction in MSP customer workflows. The AI proposes a billable ConnectWise time entry. A manager has to sign off before it posts. The legacy path was:
- Slack notification: “approval needed”
- Click link → SSO redirect → password → 2FA → approval page
- Read entry → click Approve
Median time to first action: 4 minutes. Times 30 entries/day × 4 techs in a typical 10-seat MSP, that’s 8 hours a day in manager attention to do what is functionally a bunch of thumbs-ups.
The four minutes weren’t because anyone was slow. They were because the path to approval required a full identity ceremony for what was, in information terms, a single bit (approve / reject) plus optional reason text.
We replaced it with this: the manager’s email contains Approve and Reject buttons. One click. The action runs, the audit log records it, the time entry posts to ConnectWise. No login. No 2FA.
It passes the SOC 2 questionnaire. Here’s how.
The four properties that made it auditor-equivalent
The link in each button is not a URL with ?approval_id=42. It’s a JWT (JSON Web Token) signed with our server’s private key, with four properties:
- Signed. The token’s authenticity is cryptographically verifiable. We don’t trust the URL bar; we trust the signature.
- Time-bounded. Each token expires (
expclaim), typically 72 hours after issue. After that, the click does nothing. - Single-use. A token can be redeemed exactly once. The second click on the same Approve button hits an idempotency cache and returns “already approved by you at [timestamp].”
- Idempotent. Whatever the network does — duplicate clicks, retries, browser pre-fetch — the underlying action runs at most once. Same effect every time.
The first property is what lets us skip SSO. The other three are what lets us skip the audit-team’s “but what if…” questions.
The wire format
The token payload (after jwt.decode but before signature verification) looks like:
{
"iss": "jiegou.ai",
"aud": "approval.action",
"iat": 1714502400,
"exp": 1714761600,
"jti": "01HZ8B3X9NQK5W2T7P0V4M6Y8R",
"approval_id": "appr_2bF7kQpL9xR3",
"approver_email": "alex@example-msp.com",
"action": "approve",
"account_id": "acc_4nM8tYz2",
"scope": "time_entry"
}
Three things about this payload:
- The token binds the action to a specific approver. Even if the email is forwarded, the recipient cannot redeem it as themselves — the audit log still records
alex@example-msp.comas the approver, not the forwarder. (More on the forwarded-email threat below.) - The
jtiis a ULID — unique per token, used as the idempotency key in our redemption cache. - The
scopefield is what tells the server which redemption handler to dispatch to.time_entrygoes to a different code path thancontent_publishoroutbound_message. Same JWT pattern, different scope handlers.
Validation order matters
When the click hits the server, we validate in this exact order. If any step fails, we stop:
- Verify the signature. This is the first thing — before we parse, before we trust any field. If the signature is invalid, the request gets a generic
400 Bad Requestwith no detail. We do not leak which field failed. - Check
issandaud. Wrong issuer or audience → reject. This catches tokens minted by other services or replayed across systems. - Check
exp. Expired → render a “this approval link has expired, please request a fresh one” page. - Check
jtiagainst the redemption cache. Already redeemed → render “already approved by [approver] at [time]” idempotency response. - Look up the approval record. If the
approval_iddoesn’t exist or has already been resolved through a different path (e.g., approved in the console), render “this approval has already been resolved.” - Match
approver_emailto the approval’s allowed-approvers list. Ifalex@...isn’t on the policy’s approver list for this approval, reject with403. - Execute the action. Atomically: write the redemption record (
jti→ redeemed-at), emit theapproval_eventaudit entry, dispatch the post-approval workflow (e.g., post the time entry to ConnectWise). - Render success. Show a confirmation page; do not re-issue any tokens; do not store anything in the URL bar that could be re-shared.
The key non-obvious choice is step 1. Verify before parse. Some implementations do jwt.decode first to extract the kid (key ID) for signature verification, but that means you’ve already trusted unsigned data. Use a JWT library that verifies in one call and rejects malformed tokens before exposing the claims.
The threat model — four things, what they cost the attacker
We sat down with the four most plausible attacks and asked: how does our design handle each?
1. The email gets forwarded to someone outside the company
What happens: Someone other than alex@... clicks the button.
Cost to attacker: zero — they have the token in plaintext.
Mitigation: the token binds the action to alex@...’s identity in the audit log. The forwarder cannot redeem it as themselves. The action will run, but it runs as Alex. The next governance review surfaces “Alex approved this from IP 203.0.113.7 (which is not Alex’s office)” — and Alex finds out immediately on her next login.
This is the one we don’t fully cryptographically prevent. We prevent it operationally: anomaly detection on approver IP/UA, weekly governance summary, and a 3-second confirmation page that says “you are about to approve as alex@example-msp.com — proceed?” Forward fraud is detectable, not preventable, with email links. We document this clearly in the customer SOC 2 questionnaire.
2. The token leaks in a server log somewhere
What happens: A token shows up in our nginx access log, or a 3rd-party webhook log, or a stray console.log.
Cost to attacker: depends on freshness.
Mitigation: three layers.
- Short
exp. 72-hour expiry means a leaked token is dead within 3 days. We do not issue 30-day tokens. - Single-use redemption. If the legitimate user clicked first, the token is dead. The redemption-cache lookup happens before any side effect.
- Logging hygiene. Action-link URLs are pattern-matched and redacted in our access logs and SIEM ingestion. Tokens never make it to durable storage outside the JWT itself.
3. Replay — same token clicked twice
What happens: Network retries, double-clicks, browser pre-fetch, or a malicious replay.
Cost to attacker: zero (legitimate replays are common).
Mitigation: the redemption-cache check in step 4 of validation is load-bearing. The cache is Redis, keyed on jti, with a TTL longer than exp. The first redemption sets the key atomically (SETNX); every subsequent click reads the existing record and returns the idempotent “already approved” response, including who approved and when.
Idempotency is not just a UX nicety — it’s the property that lets the system be safe under retry without leaking state.
4. Our signing key is compromised
What happens: The HMAC secret or RSA private key leaks.
Cost to attacker: they can mint arbitrary tokens for any approval.
Mitigation: four layers.
- Key isolation. The signing key lives in our KMS, not in application memory. Application servers call the KMS to sign and verify; they never possess raw key material.
- Key rotation. Keys rotate on a published schedule; old keys are accepted for verification only during a grace window equal to the longest-issued
exp. - Per-approver scoped tokens. Even with key compromise, an attacker who mints a token for
alex@...and clicks it produces an audit entry under Alex’s identity, which (per threat 1) is detectable. - Compromise response. If we detect key compromise, we revoke the key and invalidate every outstanding token. Customers see “this approval link has been invalidated for security reasons; please use the console to resolve” until reissue.
This is the threat that’s hardest to fully mitigate, and it’s why the system has a fallback: the in-console approval queue always works. Email-button approvals are an addition, not a replacement.
The honest tradeoff vs. SSO + 2FA
Email-button approvals are not strictly equivalent to SSO + 2FA. The tradeoff:
| Property | SSO + 2FA | Signed action link |
|---|---|---|
| Authenticates identity | Yes (factor + factor) | No — authenticates a single decision tied to an identity |
| Resists email forwarding | Yes (forwarded user can’t pass 2FA) | Detectable but not preventable |
| Resists key compromise | Independent of our infra (federated IdP) | Depends on our KMS hygiene |
| Friction per approval | 4 minutes | under 5 seconds |
| Auditor-trail equivalent | Yes | Yes (same approval_event shape) |
We are explicit with customers about which tradeoff they’re choosing. The recommended configuration is:
- Low-stakes, high-volume approvals (time entries, content drafts, calendar holds): email-button by default
- High-stakes approvals (financial transactions over a threshold, externally-visible commitments, compliance-affecting actions): require console + SSO; email-button reverts to a “reminder” only
- Per-account override: every customer can set the threshold for which actions get email-button vs. console-only
The threshold is a config value. The cryptography is the same.
Audit-log equivalence
Every signed-action-link redemption emits the same approval_event shape as a console approval. The entry-point field reads email_button; the rest is identical:
{
"event_type": "approval.resolved",
"approval_id": "appr_2bF7kQpL9xR3",
"approver_email": "alex@example-msp.com",
"decision": "approve",
"decision_at": "2026-04-29T14:32:18Z",
"entry_point": "email_button",
"client_ip": "203.0.113.42",
"user_agent": "Mozilla/5.0 …",
"jti": "01HZ8B3X9NQK5W2T7P0V4M6Y8R"
}
The entry_point is the only thing the auditor needs to know it came from email. Everything else — who, when, what, IP — matches console-approval evidence. Our SOC 2 evidence-export format treats them identically. The auditor reads one schema.
Five other workflows that fit this pattern
Once the JWT-redemption-cache pattern existed, we found it fit a lot of single-decision workflows:
- Calendar hold confirmations — the AI proposes “block 30 minutes Wednesday at 2pm for the Q3 review prep”; the operator confirms or moves it
- Content publish hold-and-release — drafts that pass auto-checks but are marked for sender confirmation
- Outbound message holds — the AI drafts a customer reply; the operator releases it from a held queue
- Sensitive integration unlocks — the AI requests permission to use a specific tool that’s behind a per-use approval gate
- One-time consent flows — customer-facing consent for actions that affect their data
Each of these used to require a console visit. Each now has an email-button equivalent. The cryptography is the credential. The operator’s day shrinks accordingly.
What we’d tell another team building this
If you’re building something similar, three things to lean into and three to avoid:
Lean in:
- JWT signature verification before any field parsing. Use a library that does this in one call.
- Redemption-cache idempotency keyed on
jti. This is what makes retry-safe. - Same audit-event shape regardless of entry point. Auditors should not care which path the click came from.
Avoid:
- Long
expto “be nice.” A 30-day approval link is a 30-day attack surface. 72 hours is plenty for human approvers. - Storing the token in a URL fragment hoping browsers won’t log it. They will. Treat URLs as logs-eventually.
- Silent failure modes. When verification fails, render a clear error page. The legitimate approver might be on an old token because they ignored the email for a week.
The slogan from the previous post was: trust didn’t get weaker, it relocated to a layer with less friction. Signed action links are the literal example of that. The trust moved from SSO + 2FA + password manager + browser session to well-managed signing key + short-expiry JWT + idempotent redemption cache. Friction went from 4 minutes to under 5 seconds. The audit trail got more fields, not fewer.
Start free → — or if you operate an MSP and the four-minute-approval problem above sounds familiar, book a 30-minute walkthrough and we’ll set up your account with email-button approvals on day one.