Cookie-based Authentication for your Browser Extension and Web App (MV3)
Introduction
This article will explain how to implement a cookie-based authentication that shares a state between your Browser Extension (either Chromium or Firefox) and a Web App.
Some prerequisites are:
- Either a Chrome or a Firefox extension, this example works for both of them.
- A Web App or you are planning to introduce a Web App that will use the same API as your Web Extension.
- A backend that will store the credentials of your users.
The general idea behind this approach is that your user only authenticates using the Web App, the backend API stores the auth state in the cookies, and then the Browser Extension utilizes these cookies via permission called host_permission.
When you send an API request from a background script or an extension page to a URL that is specified in your host_permission, the cookies stored for that domain are also sent with the request.
So once the user is logged in via a Web App, the cookies are stored in the browser and are automatically sent with future requests to the API.
Examples in this article are built with Typescript, Express, NextJS, and Webpack, but it will work with any other technologies.
Front-End and Back-End setup
I will not focus on the implementation of the Back-End and Front-End side of things, but I will provide an example and an explanation of how I implemented it. You can skip this section if you are only interested in the extension setup.
I opted for authentication via Google OAuth, but it will work for any other way of authentication, be it a login/password approach, other OAuth providers, or something else.
In my implementation of the authentication, I have these routes:
/auth/google
- Takes an IdToken that a Web App got from a “Sign-in with Google” button.
const { idToken } = req.body const ticket = await client.verifyIdToken({ idToken: idToken, audience: GOOGLE_CLIENT_ID });t
- Checks if a user already exists in the database or returns an existing one
const payloadUser = ticket.getPayload(); if (!payloadUser || !payloadUser.email) { return res.status(401).json({ error: 'Unauthorized' }) } const user = getOrCreateUser(payloadUser as { email: string })
- Sets an “
access_token
” and a “refresh_token
” cookie that will later be used as a way of authenticating the API requests.
const access_token = createAccessToken({ id: user.id }) const refresh_token = createRefreshToken({ id: user.id }) res.cookie('access_token', access_token, { maxAge: 1000 * 60 * 60 * 24 }) res.cookie('refresh_token', refresh_token, { maxAge: 1000 * 60 * 60 * 24 * 7, path: '/auth/refresh' })
/auth/logout
app.post('/auth/logout', (req, res) => { res.clearCookie('access_token') res.clearCookie('refresh_token') res.status(204).end() })
- When a request is sent to this route, the backend tells a browser to remove the access and refresh token cookies.
/auth/refresh
- After access_token expires, a client gets a new one using a refresh_token.
/me
app.get('/me', isLoggedInMiddleware, (req, res) => { return res.status(200).json(req.user) })
- Route that requires authentication, i.e. a valid access_token, and returns current user info.
Extension Setup
Let’s start with the most important part, manifest.json:
{ "manifest_version": 3, "name": "Extension Auth Example", "version": "1.0", "description": "This extension is an example of a cookie based auth approach.", "permissions": [ "storage" ], "host_permissions": [ "http://localhost/*", // for local development "https://api.boryssey.com/*" // backend url ], "action": { "default_popup": "popup.html" }, "background": { "service_worker": "background.js" }, "content_scripts": [ { "matches": ["https://google.com/*", "https://www.google.com/*", "http://localhost/*"], "js": ["content.js"] } ] }
What you need to focus on are permissions and host_permissions.
In permissions
you need to have “storage
”, it allows us to store the auth state, which can be shared between Content Scripts, Background Scripts, and any Extension pages without creating unnecessary load to your Back-end by repeatedly sending requests to check the auth state.
host_permissions
will include your backend URL and localhost URL to get access to their cookies. Please note that the localhost URL needs to be without a PORT, otherwise, it will not be recognized.
Now, if everything is setup correctly, you will be able to send API requests with cookies included for that backend URL.
Let’s test it and make some authenticated requests.
Sending API requests from the extension.
Everything is simple when you send an API request from a Background script or any Extension page, you do it the same way you would send a request from a normal web app.
Here you can see a normal /me
request made from the Background script after the user logged in to the Web App:
fetch('http://localhost:3005/me', { method: 'GET', headers: { 'Content-Type': 'application / json', }, credentials: 'include', }).then(response => { console.log(response); }).catch(error => { console.error(error); })
Please note that for cookies to be included in the request you need to have credentials: include
on the Front-End and credentials: true
in CORS settings on the Back-End side.
Sending API requests from the Content Script
An issue arises when you want to send a request from the content script since you will face a CORS issue and you most likely will not be able to add all the URLs to a list of allowed origins on the backend. The solution is to delegate API requests from the content script to a background script.
You can do this using browser.runtime.sendMessage API, ****so you want to setup some kind of messaging system. Here is how I’ve done it.
In the content script, you want to make a function that will be used each time you send a request:
type SendRequestPayload = { method: 'get' | 'post' | 'put' | 'delete'; url: string; data?: any; axiosConfig?: AxiosRequestConfig; }; type SendRequestResponse = { data: any; status: number; statusText: string; }; export class RequestError extends Error { constructor(message: string, public data: any) { super(message); } } const sendRequest = async (requestPayload: SendRequestProps): Promise<SendRequestResponse> => { const res = await browser.runtime.sendMessage({ eventName: 'makeRequest', payload: requestPayload }); if (res.success) return res.data; const { message, ...error } = res.error; throw new RequestError(message, error); };
You send a request using this newly created function:
sendRequest({ method: 'get', url: '/me' }).then((response) => { console.log('DEBUGGER[CONTENT_SCRIPT]', response); }).catch(error => { console.error('DEBUGGER[CONTENT_SCRIPT]:', error); });
In the background script, you need a message handler that will make the requests, return the responses, and properly pass the errors back to the client script.
const allowedMethods = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head'] as const; interface Message<T = any> { eventName: string; payload: T; } export type AxiosRequestMessage = Message<{ method: typeof allowedMethods[number]; url: string; data?: any; axiosConfig?: any; }> export const makeRequestHandler = async (request: AxiosRequestMessage) => { if (request.eventName !== 'makeRequest') return; if (!request.payload || !request.payload.method || !request.payload.url) return; const { method, url, data, axiosConfig } = request.payload; if (!allowedMethods.includes(method)) return; if (method === 'get') { return axiosInstance.get(url, axiosConfig); } return axiosInstance[method](url, data, axiosConfig); }; browser.runtime.onMessage.addListener((request, sender, sendResponse: (response?: any) => void) => { if (request.eventName === 'makeRequest') { makeRequestHandler(request) .then(data => sendResponse({ success: true, data })) .catch(error => { sendResponse({ success: false, error: { message: error.message, status: error.response?.status, response: error.response?.data }, }); }) } return true; });
Storing Authentication State in the extension storage
When the user logs in to the Web App, we want the extension to detect this and store the user info in the storage so that we can use it later.
An initial auth state check is needed in case the user installed the extension and is already signed in to the Web App. We can check if access_token
already exists by using browser.cookies.getAll API:
const saveUserInfo = () => axios.get('/me').then(response => { setStorageData({ user: response.data }); }).catch(error => { console.error(error); setStorageData({ user: null }).then(); }); const removeUserInfoFromStorage = () => setStorageData({ user: null }).then(); // Check if the user is already logged in when extension is loaded browser.cookies.getAll({}).then(cookies => { const accessTokenCookie = cookies.find(cookie => cookie.name === 'access_token'); if (!accessTokenCookie) { removeUserInfoFromStorage(); return; } saveUserInfo(); })
And we can track any cookie changes with browser.cookies.onChanged so that when the cookie is removed, we remove the stored user info.
browser.cookies.onChanged.addListener(changeInfo => { if (changeInfo.cookie.name === 'access_token') { if (changeInfo.removed) { removeUserInfoFromStorage(); return; } saveUserInfo(); } })
Later, in the extension pages or content scripts, you can track storage change events with browser.storage.onChanged.addListener(listener)
In conclusion, all you need to do is allow the extension to use cookies associated with your backend by specifying your backend URL in the host_permission
field of your manifest.json
.
I hope the information in this article will allow you to implement your own auth system for your extension. But if you have any questions, feel free to ask in the comments. Here is the link to the code used in this article: