Encrypted server-side session cookie with AWS Lambda
I ran into a problem while managing the OAuth2 flow, at one point I needed to save some information to persist between one redirect and another. This is commonly implemented with a session: the client receives a session identifier (generated by the server) and reuses it during all calls (using a cookie), the server uses the identifier to retrieve session data persisted in a database.
Wanting to keep the solution as streamlined as possible, I opted for a solution without a database: directly saving the serialized and encrypted session information in a cookie accessible only by the server. In this way the client does not just communicate an identifier, but rather all the data is already within the request (serialized in the cookie) and there is no need to retrieve it elsewhere. The client will not be able to manipulate the cookie as it is encrypted.
Request and response headers
Send cookie to client
To be able to send a cookie to the client it is necessary to specify the Set-Cookie header in the response to the client:
Set-Cookie: <cookie-name>=<cookie-value>
The first part of the value indicates the name and value that the cookie must take on.
Some parameters are added to the bottom of the string, separated by semicolons, to manage their behavior. For example, to make the cookie readable only by the server server, add “HttpOnly”, to send it only in HTTPS we add “Secure” and so on.
Set-Cookie: session=<cookie-value>; HttpOnly; Secure
Since the value is a string it is possible to serialize a value inside it:
Set-Cookie: session={"test": "abc"}; HttpOnly; Secure
Obviously we cannot insert sensitive data in this way, they would be readable in plain text from the response header and modifiable at will when sending a subsequent request. We must therefore encrypt the string so that only the server can read or write it:
Set-Cookie: session=f4b860ed2e5d0cd80f9ce97; HttpOnly; Secure
In this way the client cannot do anything other than resend the string as it received it, without knowing its de-serialized content.
Read sent cookie from client
Once the client receives this header in response, it will save the cookie in the browser and resend it in future calls within the Cookie header:
Cookie: <cookie-name>=<cookie-value>
Multiple cookies can be sent by the client by separating the values with semicolons:
Cookie: name=value; session=f4b860ed2e5d0cd80f9ce97; name3=value3
It is therefore necessary to process the header in order to extract the value corresponding to the desired cookie.
Remove cookie
There are no directives via response headers that can ask the customer to delete a specific cookie. It is possible instead to use the Max-Age parameter to set an expiration of 1 second, as soon as the response is received:
Cookie: session=none; Max-Age=1
The client browser deletes it as quickly as possible and does not resend it in the next requests.
AWS Lambda code
Create and send session to client
A Lambda function invoked by API Gateway to evaluate the response headers must return an object with the “headers” property:
/**
* Lambda handler
* @param {object} event
*/
export async function handler(event) {
const setCookieHeader = ''
return {
statusCode: 200,
headers: {
'Set-Cookie': setCookieHeader
}
}
}
To save session information securely we will therefore serialize and then encrypt the session object:
/**
* Lambda handler
* @param {object} event
*/
export async function handler(event) {
const session = {
test: 'abc'
}
const encrypted = encrypt(JSON.stringify(session))
const setCookieHeader = `session=${encrypted}; HttpOnly; Secure`
return {
statusCode: 200,
headers: {
'Set-Cookie': setCookieHeader
}
}
}
Read session from request cookies
A Lambda function invoked by API Gateway will contain the request headers within the event object. Since the value is a series of cookies separated by semicolons, they must be processed before being correctly read:
/**
* Lambda handler
* @param {object} event
*/
export async function handler(event) {
const cookieHeader = event.headers['Cookie'] || ''
const cookies = cookieHeader.split(';').map(v => v.trim()).reduce((acc, cookie) => {
const [key, value] = cookie.split('=')
acc[key] = value
return acc
}, {})
console.log(cookies)
return {
statusCode: 200
}
}
In this way we can control and access the value of the individual cookie:
{
"name": "value",
"session": "f4b860ed2e5d0cd80f9ce97",
"name3": "value3"
}
If the session cookie is present, once decrypted, it will be possible to de-serialize it and access it:
/**
* Lambda handler
* @param {object} event
*/
export async function handler(event) {
const cookieHeader = event.headers['Cookie'] || ''
const cookies = cookieHeader.split(';').map(v => v.trim()).reduce((acc, cookie) => {
const [key, value] = cookie.split('=')
acc[key] = value
return acc
}, {})
let session = null
if (cookies['session']) {
const decrypted = decrypt(cookies['session'], 'secret')
session = JSON.parse(decrypted)
}
console.log(session)
return {
statusCode: 200
}
}
Delete session
To delete the session we simply send an empty value with an expiration of one second:
/**
* Lambda handler
* @param {object} event
*/
export async function handler(event) {
return {
statusCode: 200,
headers: {
'Set-Cookie': 'session=none; Max-Age=1'
}
}
}
Security consideration
To make this system as secure as possible it is necessary to use a suitable encryption algorithm (AES 256 for example). However, it is not advisable to enter sensitive information such as API keys or passwords.
Deleting the session is just an indication to the browser to delete the cookie, it does not guarantee that it will not be reused. This problem is similar to a JWT token, once generated and sent to the client it is only possible to check its expiration but not withdraw it.
I recommend inserting an expiration date into the serialized session data and checking it accordingly before considering the session valid:
const session = {
test: 'abc',
expires: new Date(Date.now() + (1000 * 60 * 60 * 24)) // 1 day from now
}