Skip to content
Engineering

How to Approve a Billable Time Entry From Email in One Click — And Still Pass a SOC 2 Audit

JieGou's signed action links replaced a 4-minute SSO+2FA approval ceremony with a single click in an email. Here's the wire format, the validation order, the threat model, and the four properties that made it auditor-equivalent.

JT
JieGou Team
· · 10 min read

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:

  1. Slack notification: “approval needed”
  2. Click link → SSO redirect → password → 2FA → approval page
  3. 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:

  1. Signed. The token’s authenticity is cryptographically verifiable. We don’t trust the URL bar; we trust the signature.
  2. Time-bounded. Each token expires (exp claim), typically 72 hours after issue. After that, the click does nothing.
  3. 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].”
  4. 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.com as the approver, not the forwarder. (More on the forwarded-email threat below.)
  • The jti is a ULID — unique per token, used as the idempotency key in our redemption cache.
  • The scope field is what tells the server which redemption handler to dispatch to. time_entry goes to a different code path than content_publish or outbound_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:

  1. 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 Request with no detail. We do not leak which field failed.
  2. Check iss and aud. Wrong issuer or audience → reject. This catches tokens minted by other services or replayed across systems.
  3. Check exp. Expired → render a “this approval link has expired, please request a fresh one” page.
  4. Check jti against the redemption cache. Already redeemed → render “already approved by [approver] at [time]” idempotency response.
  5. Look up the approval record. If the approval_id doesn’t exist or has already been resolved through a different path (e.g., approved in the console), render “this approval has already been resolved.”
  6. Match approver_email to the approval’s allowed-approvers list. If alex@... isn’t on the policy’s approver list for this approval, reject with 403.
  7. Execute the action. Atomically: write the redemption record (jti → redeemed-at), emit the approval_event audit entry, dispatch the post-approval workflow (e.g., post the time entry to ConnectWise).
  8. 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:

PropertySSO + 2FASigned action link
Authenticates identityYes (factor + factor)No — authenticates a single decision tied to an identity
Resists email forwardingYes (forwarded user can’t pass 2FA)Detectable but not preventable
Resists key compromiseIndependent of our infra (federated IdP)Depends on our KMS hygiene
Friction per approval4 minutesunder 5 seconds
Auditor-trail equivalentYesYes (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:

  1. Calendar hold confirmations — the AI proposes “block 30 minutes Wednesday at 2pm for the Q3 review prep”; the operator confirms or moves it
  2. Content publish hold-and-release — drafts that pass auto-checks but are marked for sender confirmation
  3. Outbound message holds — the AI drafts a customer reply; the operator releases it from a held queue
  4. Sensitive integration unlocks — the AI requests permission to use a specific tool that’s behind a per-use approval gate
  5. 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 exp to “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.

security approvals jwt operator-ux soc2 engineering
Share this article

Enjoyed this post?

Get workflow tips, product updates, and automation guides in your inbox.

No spam. Unsubscribe anytime.