I wanted to build a custom app that extends Ghost’s functionality. I decided to use Ghost as system of record for memberships because it has done the hard work of integrating with payment platforms (e.g. Stripe). So, let’s jump into how we can borrow Ghost’s authentication system to authenticate users to my custom app.
Ghost Authentication
Ghost’s member authentication system is actually pretty simple. It relies on exactly 2 cookies, stored in your browser storage:
ghost-members-ssr
which stores member’s emailghost-members-ssr.sig
which contains the base64-urlencoded SHA1-HMAC signature of theghost-members-ssr
cookie.
The ghost-members-ssr.sig
cookie is used to validate the email contained in ghost-members-ssr
. This is to prevent members tampering with the cookie and being able to login as another person.
The SHA1-HMAC relies on a secret, which you can get by looking for theme_session_secret
key in Ghost’s settings database table. Your theme_session_secret
will be a 64-character hexstring.
Hacking Ghost Auth
There are 2 ways (as suggested in this forum post) to use Ghost authentication: using JWT or using cookie.
Using JWT
Ghost exposes logged-in user’s JWT token from their /members/api/session
route. If a user is not logged-in, this route will return a 204 No Content response. Ghost relies on the 2 cookies mentioned above to know if a user is logged in.
We can use a custom Javascript in our Ghost theme to get this JWT token. Using the JWT token obtained, we let our custom app verify the token’s validity using the members_public_key exposed by Ghost on the /members/.well-known/jwks.json
route.
# example of Ghost's JWT token payload.
{
"sub": "[email protected]",
"kid": "kP1NB5rgYDCIrqUbbnz_ovAvlerjHT5Nr3_ZrwmIEHU",
"iat": 1696773432,
"exp": 1696774032,
"aud": "https://your-ghost-domain.com/members/api",
"iss": "https://your-ghost-domain.com/members/api"
}
Once the token is authenticated, we can then use this token to initiate our own app session
Using Cookies
[EDIT 14 July 2024]: If you are using Ghost v5.74.0+, the previous way of validating the 2 cookies yourself and querying Ghost’s admin API for user info no longer works, because the value of ghost-member-ssr
for new users is a transientID. For old members, transientID is set to the user’s email for backward compatibility, but for new members it is set to a uuid. Therefore we can no longer get user email information from the cookie 😨.
If your app is located in the same domain (e.g. subdirectory), you can instead take advantage of the fact that browsers will forward the 2 cookies to your server. Your server can then make a GET request to https://<your-ghost-site>/members/api/member
with the 2 cookies mentioned above (ghost-members-ssr
and ghost-members-ssr.sig
) and get the entire member information.
Validating Ghost Cookies
EDITOR’s NOTE: The following section and below is no longer relevant, but kept for historical purposes.
If you want to validate the 2 cookies yourself in your custom app, here’s how to do it in Go, assuming sess
represents your ghost-members-ssr
cookie and sign
represents your ghost-members-ssr.sig
cookie.
Getting Members Info
The information you get from the tokens are pretty limited, i.e. only member’s email. But you can go further to get other attributes from Ghost via its Members Admin API
There are multiple ways to authenticate to this API, but we will use the token-authentication method as recommended by the docs for custom app integration.
First, generate the Admin API key. The key will have id and secret separated by a semicolon.
Next, we will use this key to generate a short-lived JWT token to access the Members Admin API. We will use this token to access the /ghost/api/admin/members
resource. By default it returns 15 latest users, but you can customize the response. In the example below, I filter the results based on email, using filter=email:their-email-here
, and specify the fields I needed.
That’s all you need to have a full-fledged authentication system backed by Ghost!
Story Time
How do I found out how the cookie is validated? Here’s the story of looking around into the Ghost codebase. You can follow along by opening relevant files from Ghost’s (open-source) repo.
First, I noticed that Ghost expose users’ JWT token via /members/api/session
only if you have the 2 relevant cookies above. Otherwise, it will return a 204 No Content.
So, there must be some cookie validation going on this API. Let’s find out how it is defined.
Fair enough, here’s how the api is implemented
Ok, let’s now look for getIdentityTokenForMemberFromSession
.
Here it is. The question we’re asking is how do Ghost validate the cookie, so let’s look up for _getSessionCookies
on the first line.
Oh! Ghost use the cookies package to back this functionality. But what does the cookiesOptions
look like? Let’s look at the constructor (still in the same file)
Oh ok, so Ghost pass in the keys to cookie package. But, how is this constructor used? Right-clicking on my IDE to find all references, I found
Oh ok, so now we know that the cookieKeys
comes from the settings’ theme_session_secret
, which you can grab from Ghost’s DB.
Ok now, but we still haven’t get to where the validation is done! It is actually done by the cookies
package on this (seemingly innocent) line: const value = cookies.get(this.sessionCookieName, {signed: true});
. As the usage shows above, this.sessionCookieName
is ghost-members-ssr
.
Here’s the relevant bits from cookie package’s README
new Cookies(request, response [, options])
… A Keygrip object or an array of keys can optionally be passed as
options.keys
to enable cryptographic signing based on SHA1 HMAC, using rotated credentials. …cookies.get(name [, options])
This extracts the cookie with the given name from the
Cookie
header in the request. If such a cookie exists, its value is returned. Otherwise, nothing is returned.{ signed: true }
can optionally be passed as the second parameter options. In this case, a signature cookie (a cookie of same name ending with the.sig
suffix appended) is fetched. If no such cookie exists, nothing is returned.
Ok, so the signing is done using SHA1 HMAC. So I thought, this should be easy. I whip up some Go code to validate SHA1 HMAC. I pass in the value of the ghost-members-ssr
cookie (i.e. the user email) but I get a different signature from the one stored in ghost-members-ssr.sig
. Whoa!
I’ll save you the frustration of a few hours and tell you directly the problem. I should have passed ghost-members-ssr=user[at]email.com
and not just user[at]email.com
. It is (indeed) basic cryptography lesson that you should sign over the entire cookie (including cookie name) and not just its value to prevent tampering, but oh wells. If you open the cookies
package source, you can see the relevant line (shown below).