Ghost is a popular content publishing platform with hosted and self-hosted version. As a content platform, it has a well-featured authentication system built into it. Its features include signing up members, and logging them in via email magic links. It is therefore a natural choice to want to rely on Ghost’s authentication system, especially if you are extending Ghost functionality with your custom app.

To do that, we first need to understand how Ghost’s authentication system works.

Ghost Authentication

Ghost’s member authentication system is actually pretty simple. It relies on exactly 2 cookies, stored in your browser storage:

  1. ghost-members-ssr which stores member’s email
  2. ghost-members-ssr.sig which contains the base64-urlencoded SHA1-HMAC signature of the ghost-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

If your app is located in the same domain (e.g. subdirectory), it is easier to validate the 2 cookies yourself in your custom app. This is because the JWT token payload contains only members’ email, which you can already get from ghost-members-ssr cookie.

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.

import (
    "crypto/hmac"
    "crypto/sha1"
    "encoding/base64"
)
 
func validateGhostSession(sess *http.Cookie, sign *http.Cookie) bool {
	h := hmac.New(sha1.New, []byte("your-64-character-theme_session_secret"))
	h.Write([]byte(sess.String()))
	sha := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
	return sha == sign.Value
}

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.

func getGhostUsername(ctx context.Context, email string) (string, error) {
	// https://ghost.org/docs/admin-api/#token-authentication
	v := strings.SplitN("your-ghost-admin-api-key", ":", 2)
	if len(v) < 2 {
		log.Fatalf("Wrong Ghost admin api key passed. Should be in the format of kid:secret")
	}
	id := v[0]
	secret, err := hex.DecodeString(v[1])
	if err != nil {
		log.Fatalf("Wrong Ghost admin api key passed. Secret should be a hexadecimal string")
	}
 
	iat := time.Now().Unix()
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
		"iat": iat,
		"exp": iat + 5*60,
		"aud": "/admin",
	})
	token.Header["kid"] = id
	tokenStr, err := token.SignedString(secret)
	if err != nil {
		return "", err
	}
 
	url := fmt.Sprintf("https://your-ghost-domain.com/ghost/api/admin/members?filter=email:%s&fields=email,name", email)
	req, _ := http.NewRequest("GET", url, nil)
	req.Header.Set("Authorization", "Ghost "+tokenStr)
	res, err := c.http.Do(req)
	if err != nil {
		return "", err
	}
	defer res.Body.Close()
 
	decoder := json.NewDecoder(res.Body)
	val := struct {
		Members []struct {
			Email string `json:"email"`
			Name  string `json:"name"`
		} `json:"members"`
	}{}
	err = decoder.Decode(&val)
	if err != nil {
		return "", err
	}
	if len(val.Members) == 0 {
		return "", fmt.Errorf("members not found")
	}
 
	return val.Members[0].Name, nil
}

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.

// ghost/core/core/server/web/members/app.js
membersApp.get('/api/session', middleware.getIdentityToken);

Fair enough, here’s how the api is implemented

// ghost/core/core/server/services/members/middleware.js
const getIdentityToken = async function getIdentityToken(req, res) {
    try {
        const token = await membersService.ssr.getIdentityTokenForMemberFromSession(req, res);
        res.writeHead(200);
        res.end(token);
    } catch (err) {
        res.writeHead(204);
        res.end();
    }
};

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.

// ghost/members-ssr/lib/members-ssr.js
async getIdentityTokenForMemberFromSession(req, res) {
    const email = this._getSessionCookies(req, res);
    const token = await this._getMemberIdentityToken(email);
    if (!token) {
        this.deleteSession(req, res);
        throw new BadRequestError({
            message: 'Invalid session, could not get identity token'
        });
    }
    return token;
}
 
_getSessionCookies(req, res) {
    const cookies = this._getCookies(req, res);
    const value = cookies.get(this.sessionCookieName, {signed: true});
    if (!value) {
        throw new BadRequestError({
            message: `Cookie ${this.sessionCookieName} not found`
        });
    }
    return value;
}
 
_getCookies(req, res) {
    return createCookies(req, res, this.cookiesOptions);
}
 
const createCookies = require('cookies');

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)

// ghost/members-ssr/lib/members-ssr.js
class MembersSSR {
    /**
     * @typedef {object} MembersSSROptions
     *
     * @prop {string|string[]} cookieKeys - A secret or array of secrets used to sign cookies
     * @prop {() => object} getMembersApi - A function which returns an instance of members-api
     * @prop {boolean} [cookieSecure = true] - Whether the cookie should have Secure flag
     * @prop {string} [cookieName] - The name of the members-ssr cookie
     * @prop {number} [cookieMaxAge] - The max age in ms of the members-ssr cookie
     * @prop {string} [cookiePath] - The Path flag for the cookie
     * @prop {boolean} [dangerousRemovalOfSignedCookie] - Flag for removing signed cookie
     */
 
    /**
     * Create an instance of MembersSSR
     *
     * @param {MembersSSROptions} options  - The options for the members ssr class
     */
    constructor(options) {
        const {
            cookieSecure = true,
            cookieName = 'members-ssr',
            cookieMaxAge = SIX_MONTHS_MS,
            cookiePath = '/',
            cookieKeys,
            getMembersApi,
            dangerousRemovalOfSignedCookie
        } = options;
 
        ...
 
        this.sessionCookieName = cookieName;
 
        ...
 
        /**
         * @type CookiesOptions
         */
        this.cookiesOptions = {
            keys: Array.isArray(cookieKeys) ? cookieKeys : [cookieKeys],
            secure: cookieSecure
        };
    }

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

// ghost/core/core/server/services/members/service.js#L142
module.exports.ssr = MembersSSR({
    cookieSecure: urlUtils.isSSL(urlUtils.getSiteUrl()),
    cookieKeys: [settingsCache.get('theme_session_secret')],
    cookieName: 'ghost-members-ssr',
    getMembersApi: () => module.exports.api
});

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).

// https://github.com/pillarjs/cookies/blob/master/index.js#L77
Cookies.prototype.get = function(name, opts) {
  var sigName = name + ".sig"
    , header, match, value, remote, data, index
    , signed = opts && opts.signed !== undefined ? opts.signed : !!this.keys
 
  header = this.request.headers["cookie"]
  if (!header) return
 
  match = header.match(getPattern(name))
  if (!match) return
 
  value = match[1]
  if (value[0] === '"') value = value.slice(1, -1)
  if (!opts || !signed) return value
 
  remote = this.get(sigName)
  if (!remote) return
 
  data = name + "=" + value // <---------------- Here! Here!. 
  if (!this.keys) throw new Error('.keys required for signed cookies');
  index = this.keys.index(data, remote)
 
  if (index < 0) {
    this.set(sigName, null, {path: "/", signed: false })
  } else {
    index && this.set(sigName, this.keys.sign(data), { signed: false })
    return value
  }
};