Stripe's number one priority is always security. So over the years I've gone from not worrying much at all, to always thinking security first. This has brought a lot of nuance to how I design APIs and think it's worth sharing and reflecting on exactly how that looks.
To effectively think security first, you have to start with threat modeling, figuring out what you actually care to secure today and what you will want to secure next.
Zero security is eventually bad, too much security and nothing ever happens. Great security is about carving out the right postures for the business, accepting risk where appropriate, and over time drawing stronger lines on what is table stakes for keeping the business operating and growing safely.
Security-first interfaces fit within this framing. You will have many possible interfaces and the hardest choice on which to choose will be easiest when informed by a threat model.
The only counterweight to designing strictly by your threat model is designing for change. Sometimes even if the posture is not strong enough, it will be worth it to over-optimize just to save time later. Because it is a progression: if everything goes well, security will get stronger over time. So as a provider of an interface, designing for change and a bit into the future to save your future self time is totally cool. These incremental over-wins really do get balls rolling and become self-fulfilling prophecies in the right company.
Email headers are like HTTP headers: key-value pairs that are
included in each email. Some common headers include
Subject
, To
, and From
.
When I joined Stripe, the API for specifying email headers was:
send_email(
from String:,
to: Array[String],
subject: String,
html_body: String,
custom_headers: Map[String, String]
)
This is alright, it did do the job for many years. In terms of being abusable, we were doing pretty good. For example, if our interface were instead:
send_email(
message: String
)
And we put the onus on all callers to construct email headers
and compile the full message themselves, we could not prevent
content injection and escaping issues caused by malicious user
input. With the Map[String, String]
interface we
can do that proper escaping of headers for our callers. Luckily,
most email building libraries are oriented around this style of
building up message bodies, so this interface wasn't actively
informed by a threat model.
Interestingly, you'll find weird ambiguity with the
Map[String, String]
interface. For example, what
might render out when we do:
send_email(
...,
subject: 'Hello',
custom_headers: {
'subject' => 'foo',
'SUBJECT' => 'bar',
'Subject' => 'baz'
}
)
Headers are case insensitive so how are we going to resolve
this, what takes precedence? Surely the dedicated
subject:
param should always be used, right? Like
minded engineers may disagree on the role
custom_headers
should play.
We can apply a threat model now:
Where we drew the line in this case is, "Any email header we send must be intended by an engineer." The misuse of any value of an intended header we can mitigate by turning off an email or making a code change, but given how varied email clients are in interpreting a multitude of headers we can't know all the headers are safe to pass along.
Cool! Now designing the interface is easy:
Map[String, String]
is too powerful. An engineer
could be easily forgiven for passing an arbitrary map of random,
unintended headers if they were given that interface. Instead we
need a fixed set of known headers for engineers to use and add
to over time. This interface looks like this:
send_email(
...,
custom_headers: [
EmailHeader.in_reply_to(
message_id: '<foo…>',
),
EmailHeader.reply_to(
address: ‘me@example.com’,
),
]
)
And will yield these headers in the final email:
Reply-To: [email protected]
In-Reply-To: <foo…>
Now if someone wants to use a new header, they need to add a new
header method to EmailHeader
. At a glance we can go
into the code and see every custom email header we could
possibly be sending by looking at EmailHeader
. If
you have a static type system, you can find each header's usages
in the code as well. We have our fixed set of email headers.
This interface also solves our case-insensitivity problem because header names are no longer a part of the interface. We do still struggle with ambiguity; what renders when we do:
custom_headers: [
EmailHeader.in_reply_to(message_id: '<foo…>'),
EmailHeader.in_reply_to(message_id: '<bar…>'),
]
So that still needs to be worked through with a well-defined behavior or runtime check if the type system isn't powerful enough to describe unique sets.
The other cool thing about this interface is how bespoke we can
get in terms of specifying header names and values. An engineer
might design a single EmailHeader
method that sets
multiple headers and takes well defined values. It can also be
well typed and tested in one place instead of having email
implementation details bleed all over as
String
manipulation.
I think email headers are a particularly interesting example to demonstrate progression:
EmailHeader
over a simple
Map[String, String]
is a pretty low cost
investment, but can have so many advantages if you want them.
Wait, addresses are values in email headers, right? We just had a whole section on those. This is true, but headers for email addresses are special.
Custom headers at their worst can, through exploits in escaping or overriding of headers, cause emails to send to someone else by fudging the address headers up. But after preventing that, they can really only break presentation.
Address headers need special attention because of authorization, reputation, and data exfiltration. To threat model again:
What do we care about?
Emails should only be sent to the intended individuals or trusted parties. We need to send the email to the correct audience. Tangibly, sending the email to the wrong place can get us marked as spam and make it harder to reach our users in the general case.
We need to keep user data entrusted to us secure. Emails can contain user data so sending it to the wrong place or to someone who shouldn't see it is really bad. Once an email is sent, we cannot claw it back; the damage has been done.
Brainstorm: What are some example situations?
This is really serious! It's important to call out that sometimes the best interface is no interface. For example, if someone wanted to send a confidential document as an email attachment: heck no! We would instead send an email with a link to authenticate and then download the document. We don't trust email as a medium for all communications to users.
In terms of an actual interface however, we have designed to make addressing safer.
Easiest way to deal with addresses at the interface is to not deal with addresses. Instead of:
send_email(
to: Array[String],
cc: Array[String],
bcc: Array[String],
...
)
We can meet developers where they are and do the heavy lifting for them:
send_user_email(
user: User,
...
)
And figure out all the proper addressing under the interface. This is really powerful in many ways. We've captured a much higher level intent of "send to this user" as opposed to "send to this address."
We provide more than this interface so people can do their jobs: we don't always have a user. So we generally have two APIs: the low-level one to more closely match the wire protocol and the one 99% of developers use that is tuned to business domain concerns.
The richer we can specialize for the User
scenario,
the more context we have to evolve how we communicate over time.
No one really cared to have to type
send_email(to: [user.email])
over and over anyways.
A great benefit of having the richer interfaces is observability. A user's email address can change over time but their ID is unique, durable, and safe to log. Email addresses are personally identifiable information (PII), which we don't want to log all over. There's also familiarity: everyone is already working with user IDs in their business logic.
To avoid problems with users being overshared data they should
not see, we opted to scrutinize having multiple
To
addresses, and whether we even needed
CC
and BCC
addresses at all in our
higher level interfaces. As it turned out for us, yeah they are
not really needed.
We do have use cases for CC addresses, but given how uncommon they are we've been able to manage them behind a private interface. Tied to using this interface is a well defined criteria for when it is okay to use.
Limiting emails to "one per recipient" makes modeling much easier and allows us to ensure each individual send is getting the same authorization checks. It also helps answer some product quality questions such as, "If Bob wants his email in Spanish and Mary in English, what do they get?" Each recipient gets their own email in the language they want it.
So that's addressing. It's another pretty cool example of progression:
Oftentimes platforms and infra teams can think their only job is to provide a library matching the wire protocol and they're done. In this case we were able to cut out a lot of that complexity by providing interfaces that better matched developer intent. But it happened gradually, we offered everything and then trimmed away what wasn't necessary by applying our threat model.
Sometimes the risk is too high and no interface is the very best interface. As much as we iterate on interfaces there is a limit to what we can encourage and prevent by having one at all. We used to support BCC, but now we don't at all.
Ugh, there's so much more to talk about. But this is enough for now. If you're interested in more API design, check out my other article Interface Design: Developer Experience and let me know if you'd like to read more on Twitter or shoot me an email (with headers).
Go create a threat model and make an interface that satisfies it (maybe better than needed)!
Thanks for reading!