Skip to content

Instantly share code, notes, and snippets.

@jed
Last active August 30, 2024 08:37
Show Gist options
  • Save jed/6147872 to your computer and use it in GitHub Desktop.
Save jed/6147872 to your computer and use it in GitHub Desktop.
How to set up stress-free SSL on an OS X development machine

How to set up stress-free SSL on an OS X development machine

One of the best ways to reduce complexity (read: stress) in web development is to minimize the differences between your development and production environments. After being frustrated by attempts to unify the approach to SSL on my local machine and in production, I searched for a workflow that would make the protocol invisible to me between all environments.

Most workflows make the following compromises:

  • Use HTTPS in production but HTTP locally. This is annoying because it makes the environments inconsistent, and the protocol choices leak up into the stack. For example, your web application needs to understand the underlying protocol when using the secure flag for cookies. If you don't get this right, your HTTP development server won't be able to read the cookies it writes, or worse, your HTTPS production server could pass sensitive cookies over an insecure connection.

  • Use production SSL certificates locally. This is annoying because SSL credentials shouldn't be passed around lightly, and ideally should only exist on blessed machines. Plus, even with a wildcard certificate, coming up with a scheme to put development hosts and production hosts under the same namespace has some weird edge cases (if the production app is served off of myproject.com, where is the development app served from?)

  • Give up on HTTPS entirely. This is annoying because it's like, 2013 already, amirite.

So here's my approach for a modern HTTPS workflow, in four steps:

  1. Resolve a top-level domain for all development work,
  2. Create a wildcard SSL certificate for each project,
  3. Avoid HTTPS warnings by telling OS X to trust the certificate, and
  4. Bask in easy HTTPS.

Let's get started.

Install Homebrew if it's not already installed

ruby -e "$(curl -fsSL https://raw.github.com/mxcl/homebrew/go)"

Resolve a top-level domain for all development work

The most common way to get a given host to resolve to your local machine is to manually edit your /private/etc/hosts file, which is annoyingly O(n2), since you need to add an entry for each subdomain of each project you're working on.

With a little more work up front, we can streamline our development workflow by resolving a single top-level domain (herein as TLD) to our development box and never touch the hosts file again.

One tool that can help us do this is Dnsmasq, a lightweight DNS forwarder. Here's how we'll install it:

brew install dnsmasq
mkdir -pv $(brew --prefix)/etc
sudo cp -v $(brew --prefix dnsmasq)/homebrew.mxcl.dnsmasq.plist /Library/LaunchDaemons
sudo launchctl load -w /Library/LaunchDaemons/homebrew.mxcl.dnsmasq.plist
sudo mkdir -pv /etc/resolver

Once it's installed and running, we need to choose what TLD we'd like to resolve to our local box. OS X reserves the .local TLD, and many developers use .dev, but I like the idea of using names as TLDs, to make it easier to reason about my local environment within the context of other developers on the same project. So I'm going to have Dnsmasq locally resolve all hosts ending in my OS X user id (also known as a short name, and what you get when you type whoami in the terminal). Since my user id is jed, that means everything from apple.jed to zebras.jed to apple.zebras.jed will resolve to 127.0.0.1.

All of the following shell scripts assume you're cool with using your user id as your top-level domain, but feel to change them accordingly by replacing $(whoami) with your chosen TLD.

echo "address=/.$(whoami)/127.0.0.1" | sudo tee -a $(brew --prefix)/etc/dnsmasq.conf
echo "nameserver 127.0.0.1" | sudo tee /etc/resolver/$(whoami)

Now we need to make sure that worked, by spinning up a server and hitting it.

cd /Applications
sleep 1 && open "http://some.domain.$(whoami):9520" &
python -m SimpleHTTPServer 9520

This should open a new browser that shows the contents of your Applications folder, like this:

applications folder

The browser might tell us that the DNS lookup failed, like this:

failed dns

In this case, we'll need to restart the machine to make sure the setup above has taken effect.

Create a wildcard SSL certificate for each project

Now that we know our DNS works, we need to create an SSL certificate for our development environment. Since (for good reason) browsers won't trust certificates that cover an entire TLD, we need to create a wildcard certificate for each domain we want to use. Also, since we want to cover both arbitrary subdomains (*.yourproject.tld) and the domain apex (yourproject.tld), we'll need to use the Subject Alternative Name X.509 extension.

First, let's create a new directory named for our project and cd into it.

mkdir ~/Desktop/myproject && cd $_

Next, let's create a temporary configuration file, and feed it into openssl to create our certificate.

cat > openssl.cnf <<-EOF
  [req]
  distinguished_name = req_distinguished_name
  x509_extensions = v3_req
  prompt = no
  [req_distinguished_name]
  CN = *.${PWD##*/}.$(whoami)
  [v3_req]
  keyUsage = keyEncipherment, dataEncipherment
  extendedKeyUsage = serverAuth
  subjectAltName = @alt_names
  [alt_names]
  DNS.1 = *.${PWD##*/}.$(whoami)
  DNS.2 = ${PWD##*/}.$(whoami)
EOF

openssl req \
  -new \
  -newkey rsa:2048 \
  -sha1 \
  -days 3650 \
  -nodes \
  -x509 \
  -keyout ssl.key \
  -out ssl.crt \
  -config openssl.cnf

rm openssl.cnf

Now we have two files in our project directory: ssl.key, the private key used to sign the certificate, and ssl.crt, the certificate itself.

At this point we have almost everything we need to fire up a local HTTPS server, but there's one problem.

certificate not trusted

Any content we try to serve over HTTPS from this domain gets IMMA-LET-YOU-FINISHed by a scary message like the one above, warning us that the presented certificate is not trusted. This message differs by browser, and you might be tempted to go ahead and ignore it, but that's not a good habit to get into, and is likely to lead to development complexity down the road.

There's a better way; we can tell OS X to trust the certificate we just created so that we don't have to see this screen ever again, by adding the certificate to our keychain.

Avoid HTTPS warnings by telling OS X to trust the certificate

Since our certificate is self-signed, we'll always get a warning when using it for our HTTPS site. We need to use Keychain Access to tell OS X to enhance its calm for this domain.

keychain access

  1. Open the certificate in Keychain Access.
open /Applications/Utilities/Keychain\ Access.app ssl.crt
  1. Click Don't Trust.

  2. Select the newly imported certificate, which should appear at the bottom of the certificate list, and click the [i] button.

  3. In the popup window, click the ▶ button to the left of Trust, and select Always Trust for When using this certificate:.

  4. Close the popup window.

  5. When prompted, enter your password again and click Update Settings.

  6. Close Keychain Access.

Bask in easy HTTPS

Now that OS X knows that our self-signed certificate is legit, let's spin up an HTTPS server to make sure it all works. You can use Apache or Nginx or whatever you like, but here we'll use nodejs:

sleep 1 && open "https://myproject.$(whoami):8443" &
sleep 1 && open "https://subdomain.myproject.$(whoami):8443" &

node <<-EOF
  var https = require("https")
  var fs    = require("fs")

  var options = {
    key: fs.readFileSync("ssl.key"),
    cert: fs.readFileSync("ssl.crt")
  }

  var server = https.createServer(options, function(req, res) {
    res.writeHead(200, {"Content-Type": "text/plain"})
    res.end("It worked!\n")
  })

  server.listen(8443, console.log)
EOF

As you can see, the correct content is being served by both the apex domain and subdomain.

apex domain

subdomain

Note that this will satisfy Chrome and Safari, but since Firefox doesn't inherit the same keychain from OS X, it will tell you that the certificate is untrusted. In this case, click I Understand the Risks, then Add Exception..., and then Confirm Security Exception_ when prompted.

How do you HTTPS?

This guide is a snapshot of my thoughts on how to simplify HTTPS workflow for web development, but if you have any feedback or want to share some tips about your workflow, drop a line at @jedschmidt or leave a comment below.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

yeah, so you should stop reading now, unless you want a data dump of all the images used in this missive.

@jed
Copy link
Author

jed commented Aug 6, 2013

thanks, sean. i'm going to try it on my end and make some changes if that works.

@jed
Copy link
Author

jed commented Aug 6, 2013

i trimmed off a few steps, opening the certificate directly in Keychain Access and adding it to the login certs. thanks, @seancribbs!

@schickling
Copy link

I also needed to restart to browser and webserver to make things changes

@bronius
Copy link

bronius commented Dec 4, 2014

THANK YOU THANK YOU THANK YOU THANK YOU!!!
Enough cheese- I did it with breeze once I understood the magic which I shall share for anyone else who may want it. I am *.dev in localdev, so:

  • I modified your openssl.cnf to remove the subjAltName lines and below
  • Instead of ssl., I used dev.key and dev.crt
    And I ran it once as-is, once with modifications: Modified one required me to go *back
    and change the keychain from Login to System (for some reason it didn't take from the earlier dialog?).

Thanks for making my localdev SSL whir like a champ. Was also useful for application API development where the app developer never implemented a "trust this untrusted cert?" function.

@acrookston
Copy link

Any thoughts when developing for external devices like phones?

@ryross
Copy link

ryross commented Dec 24, 2014

@acrookston I typically use xip.io and create widcard vhosts with apache. http://stackoverflow.com/a/24708898

@kesor
Copy link

kesor commented Dec 24, 2014

How about creating one local Certificate Authority (CA) that you add to your Keychain, and signing all per-domain certificates with that. Makes the keychain a bit slimmer on the amount of certificates, but still gets you the browser-trusts-me effect.

@paulschreiber
Copy link

Don't use sha1. Use sha256 instead. See https://shaaaaaaaaaaaaa.com/ for more details.

@iangcarroll
Copy link

Is there a reason for selecting don't trust and then going back in and selecting trust? Seems pointless...

@robbiev
Copy link

robbiev commented Dec 29, 2014

Cool stuff, also see my tool https://github.com/robbiev/devdns for a convenient way to set up local DNS

@arobbins
Copy link

So awesome. Works great, thank you.

@AHaymond
Copy link

This whole setup falls apart with

echo "address=/.$(whoami)/127.0.0.1" | sudo tee -a $(brew --prefix)/etc/dnsmasq.conf
echo "nameserver 127.0.0.1" | sudo tee /etc/resolver/$(whoami)

/etc/resolver is a directory, and there is no $(whoami) file

@skseth
Copy link

skseth commented Jul 5, 2015

Thanks for the great write-up and tips. I got this working perfectly on my machine.

Maybe as @AHaymond says, you could add a line to create the directory /etc/resolver if it does not exist.

@lukevenediger
Copy link

Thanks - this worked great!

@xcambar
Copy link

xcambar commented Jan 6, 2016

Works perfectly alongside https://github.com/cameronhunter/local-ssl-proxy

On OSX El Capitan, I has to replace CN = *.${PWD##*/}.$(whoami) with CN = ${PWD##*/}.$(whoami), otherwise neither Firefox nor Chrome would accept the certificate.

👍 great gist!

@mavieth
Copy link

mavieth commented Feb 26, 2016

Great tutorial. I was able to get the domain and subdomain working for NodeJS, do you have any idea how i could make it work using Apache2 and with my working directory being ~/Sites/?

Copy link

ghost commented May 4, 2016

After running through this my whole system is ruined. It appears there is a duplicate apache

@yyaabboopp
Copy link

Worked perfectly. Great writeup, thank you!

@vebjorn
Copy link

vebjorn commented Jan 27, 2017

Thanks for this, after a tremendous amount of googling this saved my ass :)

@jkirkell
Copy link

jkirkell commented May 6, 2017

has anyone had problems with this recently? dnsmasq works like a charm, but the certificate generation doesn't show errors, but when using any modern Safari, Chrome, FF, I always get this. Even when I trust the certificate it still doesn't do anything else.

Your connection is not private
Attackers might be trying to steal your information from some.projects.jeff (for example, passwords, messages, or credit cards). NET::ERR_CERT_COMMON_NAME_INVALID

@jkirkell
Copy link

jkirkell commented May 8, 2017

Making the following minor changes allows the certificate to work on Sierra 10.12.4 in Chrome 58, Canary 60, and Safari. Firefox is the only browser (I use) that is not showing the cert as a trusted cert and requires adding an exception. If anyone else has thoughts on Firefox let me know.

cat > openssl.cnf <<-EOF
[req]
distinguished_name = req_distinguished_name
x509_extensions = v3_ca
prompt = no
[req_distinguished_name]
CN = .${PWD##/}.$(whoami)
[v3_ca]
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alternate_names
[alternate_names]
DNS.1 = .${PWD##/}.$(whoami)
DNS.2 = ${PWD##*/}.$(whoami)
EOF
though not much changed. KeyUsage line and just renamed the v3_req to v3_ca and alt_names to alternate_names

then switched SHA1 to SHA256
openssl req -new -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes -keyout ssl.key -out ssl.crt -config openssl.cnf

@folbert
Copy link

folbert commented May 15, 2017

Thank you @jed and @jkirkell !

@danielcompton
Copy link

danielcompton commented Sep 15, 2017

@jkirkell it's likely the SHA1 to SHA256 transition that fixed things for you, as SHA1 certs are very deprecated.

@christianalfoni
Copy link

christianalfoni commented Oct 20, 2017

I built an NPM tool out of it: https://www.npmjs.com/package/create-ssl-certificate

Thanks a bunch for this!

@madduci
Copy link

madduci commented Oct 25, 2017

this could be easily extended to linux and windows as well 👍

@vysogot
Copy link

vysogot commented Mar 23, 2018

Thank you! To make it work in Firefox 59 you need to use SHA256 and a domain other than .dev (so whoami is usually a good way to go)
refer here: https://support.mozilla.org/pl/questions/1204477

@glukki
Copy link

glukki commented May 31, 2018

Since some version of brew there's a brew services command, which lets you manage services, and make them start after reboot.

In this case it could be just

sudo brew services start dnsmasq

instead of

sudo cp -v $(brew --prefix dnsmasq)/homebrew.mxcl.dnsmasq.plist /Library/LaunchDaemons
sudo launchctl load -w /Library/LaunchDaemons/homebrew.mxcl.dnsmasq.plist

@filmo
Copy link

filmo commented Jul 10, 2018

Can anybody comment on how to use this in conjunction with built in Mac OSX apache rather than via Python http.server?

I'm going to assume it's all more or less the same except that we would need to change our httpd.conf files to point to the generated ssl.crt and ssl.key in the right places.

@filmo
Copy link

filmo commented Jul 12, 2018

Also note. I found that doing this messed up the ability of my Mac to share internet from Ethernet to Wifi. Only upon removing homebrew.mxcl.dnsmasq.plist from the LaunchDaemons folder was my mac capable of sharing internet over wifi again.

@glukki
Copy link

glukki commented Aug 4, 2020

My certificate suddenly stopped working yesterday with an error in Chrome: ERR_SSL_KEY_USAGE_INCOMPATIBLE.

I had to generate a new one (and add it to Keychain and trust it) with a slightly different openssl config:

keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment

Yet, I didn't get into details, why certificate generated with the original config stopped working, and why mentioned changes fixed the issue, just googled it >_<

I've also changed generated certificate validity time to 365 days, because of upcoming changes to browsers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment