Securing Apps With SIWE & Non-Extractable Session Keys

This exact attack recently caused breaches to the Discord servers of many prominent Web3 communities.

Securing Apps With SIWE & Non-Extractable Session Keys

This article describes the problem of session hijacking and how you can create a more secure session by combining Sign-in with Ethereum and non-extractable keys from the WebCrypto APIs.

The Problem

A session is established in essentially all applications where users sign in. These sessions typically rely on cookies, JWTs, or similar technologies. The way they are established is roughly as follows: a user arrives at an application and authenticates in some way, usually through signing in with a username and password. The application's backend server verifies the login and creates a valid session for the user.

The session itself is stored locally in the user’s browser as a cookie or JWT. Unfortunately, this client-side data can be stolen. This is normally referred to as session hijacking—and this exact attack recently caused a breach of the Ceramic discord server.

Session hijacking works when a hacker is able to intercept the user's JWT (or other session ID). They can then use this locally on their computer to fool the application backend server into thinking that they are in fact signed in as the user. Once they are signed in, they could take all kinds of malicious actions not approved by the user.

An unfortunate fact is that in many cases, Sign-in with Ethereum is also vulnerable to this because the suggested way sessions are established is through cookies. So, what can we do about this?

Non-Extractable Session Keys

The main problem with the sessions outlined above is that the data that authenticates the session can be stolen. What if there was a way to have a session where this information was impossible to copy? Well, in fact, the Web Crypto API provides provides such functionality. This API can (among other things) be used to generate cryptographic key pairs. By default, both the public and the private key for these key pairs live in memory on web pages, which wouldn't improve the situation at all.

However, the private key can be made “non-extractable” by setting the extractable property to false. This makes it impossible for any JavaScript code or browser extension to access the private key material, effectively making it impossible to steal it. These keys can also be persisted in your application by storing them in IndexedDB.

It's worth noting that this solution is not perfect. If a malicious script somehow gets injected into the client, it may still use the user's key while they have their tab open. However, as soon as the tab is closed, the hacker could no longer use this key, which is a significant improvement from complete loss of a session.

How to use DIDSession

Ok, so how can we use this to make our Sign-in with Ethereum sessions more secure? DIDSession is an open-source library that we created at Ceramic to make it easy to use non-extractable keys to secure Sign-in with Ethereum sessions. This library treats your Ethereum address as a did:pkhand the non-extractable session key as a did:key. A SIWE signature is used to delegate permissions from your Ethereum address to the session key. A server can then verify that a message originated from a session key that has been granted permissions from the Ethereum account. To use DIDSession, you first need to install it in your application.

Creating a session

First we create a session. The code below will prompt the user to sign a SIWE message and finally a session will be created. This session will automatically be persisted in IndexedDB.

import { DIDSession } from 'did-session'
import { EthereumWebAuth, getAccountId } from '@didtools/pkh-ethereum'

const ethProvider = window.ethereum // Metamask, WalletConnect or similar
const addresses = await ethProvider.request({ method: 'eth_requestAccounts' })
const accountId = await getAccountId(ethProvider, addresses[0])
const authMethod = await EthereumWebAuth.getAuthMethod(ethprovider, accountId)

const session = await DIDSession.get(accountId, authMethod)

Sign a message and send to server

Using the session, the application can now sign any arbitrary message and send this message and the object-capability (SIWE message) along to a server. All signed messages will happen in the background without any further action required by the user.

const jws = await session.did.createJWS({ hello: 'world' })

const request = {
  capability: session.did.capability
  jws,
}

// send the request object to your backend server

Validate user action on server

Once the request is received on a server, its signature can be verified; simply looking at the object-capability we can see which user took the given action.

import { DID } from 'dids'
import * as KeyResolver from 'key-did-resolver'

const did = new DID({ resolver: KeyResolver.getResolver() })

const result = await did.verifyJWS(request.jws, { capability: request.capability })

console.log('User account:', request.capability.p.iss)
console.log('User action:', result.payload)

Benefit of Using Ceramic

While DIDSession can improve security of SIWE, when interacting with a backend server, we can take applications one step further by storing user data on a decentralized network. This is the reason we built Ceramic, and DIDSession is the native way to write data into the protocol. You can read more on our docs site.