Using AWS Cognito to secure a Google Cloud Functions API for a React web app

Gareth Cronin
11 min readFeb 4, 2024

--

I build small responsive web applications on serverless back ends with an ever-evolving stack and toolkit. In the time since I started writing about it a couple of years ago I’ve settled into:

For my last few small projects I’ve used Google accounts directly for authentication, which works well, but I like to provide users with good old email/password account creation and authentication if they prefer. Given my stack includes Firestore, I have used Firebase authentication for this in the past, but I’ve struggled with out-of-date Firebase UI authentication components not playing nicely with newer versions of React and Material UI. For my latest project I decided to have a go with AWS Cognito.

My requirements

I wanted the authentication for my app to behave like this:

  • User logs into a welcome state in the app where the call to action is to click a “log in” button
  • User can create a new email-based account, or log in with an existing email or Google account
  • While using the app, if the user’s access token expires, the app should transparently refresh it in the background without interrupting their workflow
  • If the app can’t refresh the user’s token, it should bounce them back to the log in

Past experience told me that none of this is as simple as I’d like it to be!

AWS Cognito

Cognito is very full-featured. It has a user/group management system, can manage scopes for APIs, and can be customised, integrated, and federated in many ways. My needs are reasonably simple and the configuration items I use are illustrated below:

  • The root object is a “user pool” with an ID and AWS ARN
  • The user pool has a “sign-in” configuration that can include one or more federated ID providers: I configured a Google provider with the OAuth2 client ID and secret from my Google Cloud Platform OAuth2 configuration
  • The user pool can also include one or more “app client” configurations: for my purposes I only need one client configured with the authorization grant authentication flow enabled
Basic Cognito config model

Basic sign-in flow

The OAuth2 authorization code grant is (necessarily) a bit complicated. AWS Cognito makes things a easier by providing a hosted user interface. Cognito provides a URL that can be used from the main application to redirect web app users to the hosted UI. The Cognito base URL can be the name of your project as a subdomain on an AWS Cognito domain, or you can use a custom domain (I just used the Cognito domain since it looks pretty legit to a user anyway). Here’s code from my React UI Login component that builds the URL:

const baseUrl = `${window.location.protocol}//${window.location.host}`;
const redirectUrl = `${baseUrl}/token`
const loginUrl = `${COGNITO_BASE_URL}/login?client_id=${CLIENT_ID}&response_type=code&scope=email+openid+phone&redirect_uri=${redirectUrl}`

I put the loginUrl above on a button in my login dialog in Material UI:

<Button variant='contained' href={loginUrl}>Log in</Button>

The hosted UI has a full account creation and log in experience which can be customised in many ways. Here’s a screenshot from my app. All I’ve customised is the image (my app icon courtesy of Flaticons) and you can see it’s a very usable email or Google sign-in experience with a link to create an email account if required. It also works really well on mobile.

Screenshot from my app with minimal customisation

Once the user has successfully authenticated, they are sent to a redirect URL of your choice configured in the app client integration with a short-lived access code as a parameter. E.g. in development on a local machine, a redirect in my app looks like this: http://localhost:5173/token?code=dcb6ac5b-74fa-485b-8274-f1feba2403da

At this point we need to consider the back end. The authorization grant flow requires a secure place to hold the Cognito client secret. Because I build my APIs using Google Cloud Functions, I use the Google Secret Manager to hold the secret, with the service account that executes the functions authorised to access the secret. The Cloud Functions code with the endpoint reads the secret, forms the request to the Cognito token endpoint and retrieves a blob of JSON containing a refresh token, access token, and ID token:

  • The ID token contains metadata about the user
  • The access token is a truncated version of the ID token suitable for passing as a bearer token in actual API calls
  • The refresh token can be used to generate new access and ID tokens when they expire (the default expiry is 60 minutes)

Here’s a high level collaboration diagram illustrating how this all hangs together:

When the redirect with the code comes back from Cognito, I let React Router load up a Token component that makes the API call to my back end Cloud Functions endpoint to exchange the code for a token. Here’s that component (don’t worry about the context hooks for now — I’ll deal with those further on):

import { Box, CircularProgress, Grid, Stack, Typography } from '@mui/material';
import React from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useFeedbackContext } from '../context/FeedbackContext';
import { useUserContext } from '../context/UserContext';

const Token: React.FC = () => {

const navigate = useNavigate();
const { user, loginWithCode } = useUserContext();
const { showMessage, showErrorMessage } = useFeedbackContext();

const [searchParams] = useSearchParams();
const [loggingIn, setLoggingIn] = React.useState<boolean>(false);

React.useEffect(() => {
const code = searchParams.get('code');
if (code && !loggingIn) {
setLoggingIn(true);
}
}, [loggingIn, searchParams]);

React.useEffect(() => {
const code = searchParams.get('code');
if (loggingIn && code) {
(async () => {
const user = await loginWithCode(code);
if (user) {
showMessage(`Logged in as ${user?.email}`);
navigate('/');
setLoggingIn(false);
}
else {
showErrorMessage('Login failed');
navigate('/login');
}
})();
}
}, [loggingIn, loginWithCode, navigate, searchParams, showErrorMessage, showMessage, user?.email]);

return (
<Box justifyContent="center" >
<Grid item xs={12} id='stashsite-body'>
<Stack justifyContent="center" alignItems="center" spacing={2}>
<Typography variant="body1" component="h1" gutterBottom>Logging in</Typography>
{loggingIn && <CircularProgress />}
</Stack>
</Grid>
</Box>
);
};

export default Token;

I use Express in my Google Cloud Functions. The following code shows the handler for the endpoint matching the token exchange called above. AWS provides an NPM called aws-jwt-verify for verifying and reading data from the Cognito ID and access JWT tokens. It checks the token is signed by the AWS Cognito private key and unpacks the user data. I use the cognito:username as the primary user identifier and it’s at the token exchange that I create the user in Firestore if they don’t exist yet.

export const exchangeToken = async (req: express.Request, res: express.Response) => {
if (!req.query.code) {
res.status(400).send('Missing code');
return;
}

const clientSecret = await fetchSecret(COGNITO_CLIENT_SECRET_ID);
if (!clientSecret) {
res.status(500).send('Unable to fetch client secret');
return;
}

const base64encodedIdandSecret = Buffer.from(`${COGNITO_CLIENT_ID}:${clientSecret}`).toString('base64');

const headers = new Headers();
headers.append('Authorization', `Basic ${base64encodedIdandSecret}`);
headers.append('Content-Type', 'application/x-www-form-urlencoded');

const params = new URLSearchParams();
params.append('code', req.query.code as string);
params.append('grant_type', 'authorization_code');
params.append('redirect_uri', redirectURL);
params.append('client_id', COGNITO_CLIENT_ID);

const requestOptions = {
method: 'POST',
headers: headers,
body: params,
redirect: 'follow'
} as RequestInit;

try {
const exchangeResponse = await fetch('${COGNITO_BASE_URL}/oauth2/token',
requestOptions
);

const exchangeData = await exchangeResponse.json();
if (exchangeData) {
const idToken = exchangeData.id_token as string;
const unpackedIdToken = await verifyAndUnpack(idToken, 'id');
if (unpackedIdToken) {
const user = unpackedIdToken['cognito:username'] as string;
await createUserIfRequired(user);
}
else{
return res.status(500).send({ error: 'Token unpack failed' });
}
res.send(exchangeData);
}
else{
res.status(500).send({ error: 'Token exchange failed' });
}
}
catch (err) {
res.status(500).send({ error: 'Token exchange failed' });
}
};

The user useContext hook

When the token exchange endpoint returns, I have all three tokens. My apps are too small for a full third-party state manager. From time to time I try one out, but I always end up back with basic vanilla React. To quote the React docs onuseContext:

Context lets the parent component make some information available to any component in the tree below it — no matter how deep — without passing it explicitly through props.

The boilerplate for building out a context hook in TypeScript is a bit bloated, but it works. You create:

  • An interface for the props to use when creating the context with React.createContext()
  • An instance of your context which implements the interface
  • A provider, which returns the context instance, wrapping any child React components passed to it

In my app, I instantiate the providers (I have one for user feedback and one for app data as well) in the top level ViteApp.tsx component and wrap the top level UI components in the React Router (“StashSite” is the name of my app):

import { UserProvider } from './context/UserContext';

// ...

return (
<UserProvider>
// ...
<BrowserRouter>
<Routes>
<Route path="/" element={<Stashsite />} />
// ...
</Routes>
</BrowserRouter>
// ...
</UserProvider>
);

The interface for my UserContext:

interface UserContextProps {
user: User | null; // the actual user
userState: UserState; // shows the logged in state
checkToken: () => Promise<User | null>; // method for refreshing the token if required
loginWithCode: (token: string) => Promise<User | null>; // the token exchange method called from Token.tsx
logout: () => void;
}

Because a user might not be logged in, a token can expire at any time, and we might be in the middle of refreshing or exchanging, the userState is important all through the user interface. I don’t schedule proactive checks for expiry, instead I use the checkToken method to check and refresh if necessary before attempting an API call with the token — I’ll explain that properly a bit further on. For now, just know that userState is a simple state machine with these states:

export const enum UserState {
INITIAL = 'Initial', // initial state
CHECKING_STORED_USER = 'CheckingStoredUser', // reading the user in local storage
NO_REFRESH_TOKEN = 'NoRefreshToken', // no valid token stored, user needs to log in
CHECK_TOKEN = 'CheckToken', // need to check the token for validity and expiry
CHECKING_TOKEN = 'CheckingToken', // calling the refresh token endpoint if required
LOGGED_IN = 'LoggedIn' // ready to go
}

The loginWithCode method exposed in the provider is the one I called in the Token component earlier in this article. It’s called when the authorisation code is first returned as a parameter to the redirect URL. Here’s how it works:

// ...

const [user, setUser] = React.useState<User | null>(null);
const [userState, setUserState] = React.useState<UserState>(UserState.INITIAL);

// ...

const loginWithCode = useCallback(async (code: string) => {
// call the token exchange endpoint
const newTokens = await exchangeToken(code);
if (newTokens) {
// verifyAndUnpack is a wrapper for the AWS aws-jwt-verify package
const unpackedToken = await verifyAndUnpack(newTokens.id_token, 'id');
if (unpackedToken) {
const unpackedUser = {
uid: unpackedToken['cognito:username'] as string,
email: unpackedToken.email as string,
access_token: newTokens.access_token,
id_token: newTokens.id_token,
refresh_token: newTokens.refresh_token,
exp: unpackedToken.exp,
} as User;
setUser(unpackedUser);
saveValueToLocalStorage(LOCAL_STORAGE_KEY, unpackedUser);
setUserState(UserState.LOGGED_IN);
return unpackedUser;
}
}
return null;
}, [setUser]);

The React state hooks included above are part of the implementation of the provider for the context hook. The read values user and userState are exposed to any code that uses the context hook, but I keep the setters private inside the provider.

The rest of the code is extracting the three tokens and the user information along with the expiry time, and putting it all into local browser storage for later. At this point the user is logged in, and front end code can use a combination of user and userState to check state and user information, e.g. this shows a progress indicator any time the app is in the middle of logging in or checking expiry:

// ...

const { userState } = useUserContext();

// ...

{(userState === UserState.CHECKING_STORED_USER || userState === UserState.CHECKING_TOKEN)
&& <CircularProgress />}

Checking and refreshing

Before making a call with a token, we need to check for expiry and refresh if required. In this example I am saving data for the user:

// ...

const { user, checkUser, userState } = useUserContext();

// ...

const handleSaveBookMark = async (urlToSave: string) => {
if (user) {
const cleanedText = extractUrlFromText(urlToSave);
const checkedUser = await checkUser();
if (cleanedText && allBookmarks !== null && userState === UserState.LOGGED_IN && checkedUser) {
setSaving(true);
const savedBookmark = await saveBookmark(checkedUser, cleanedText, showErrorMessage, showMessage);
if (savedBookmark) {
setAllBookmarks([...allBookmarks, savedBookmark]);
}
setSaving(false);
}
}
}

The local checkUser method in turn calls the checkToken method in UserContext. checkToken will refresh the token if required and return the user, avoiding interrupting the user on an expired token. If checkToken returns nothing, it means we don’t even have a valid refresh token, so we should redirect to the login page using React Router’s useNavigate hook:

// ...

const navigate = useNavigate();
const { checkToken } = useUserContext();

// ...

const checkUser = React.useCallback(
async () => {
const tokenOK = await checkToken();
if (!tokenOK) {
navigate('/login');
}
else {
return tokenOK;
}
}, [navigate, checkToken]);

The checkToken method returns the existing user from the state hook or the local storage if it is still valid. If it has expired, it will attempt a refresh and then return the user:

const checkToken = useCallback(async () => {
if (user) {
const now = new Date();
// if we are within five minutes of expiry, attempt a refresh
if ((user.exp * 1000) - now.getTime() < FIVE_MINUTES) {
// call the refresh token endpoint
const newTokenSet = await refreshToken(user.refresh_token);
if (newTokenSet) {
// verifyAndUnpack is a wrapper for the AWS aws-jwt-verify package
const unpackedToken = await verifyAndUnpack(newTokenSet.id_token, 'id');
if (unpackedToken) {
const refreshedUser = {
...user,
access_token: newTokenSet.access_token,
id_token: newTokenSet.id_token,
refresh_token: newTokenSet.refresh_token,
exp: unpackedToken.exp,

};
setUser(refreshedUser);
saveValueToLocalStorage(LOCAL_STORAGE_KEY, refreshedUser);
return refreshedUser;
}
else {
// shouldn't really happen, but you never know
// clear the user and the local storage
setUser(null);
removeValueFromLocalStorage(LOCAL_STORAGE_KEY);
return null;
}
}
else {
// clear the user and the local storage
setUser(null);
removeValueFromLocalStorage(LOCAL_STORAGE_KEY);
return null;
}

}
else {
return user;
}
}
else {
return null;
}
}, [user]);

The endpoint for refreshing the token looks almost identical to the code for exchanging a code for a token. The only difference is the parameters sent to Cognito. The relevant lines in the back end code:

const params = new URLSearchParams();
params.append('refresh_token', req.body.refresh_token as string);
params.append('grant_type', 'refresh_token');
params.append('client_id', COGNITO_CLIENT_ID);

Protecting API calls

Because I use Express in the Google Cloud Functions back end, using Express middleware is a simple way to intercept each call and ensure the token is valid.

I use a very simple access control pattern. Each of my endpoints has the unique user ID as the first part of the URL. The middleware checks that against the user ID in the Cognito JWT token and rejects it if the token is invalid or the user ID doesn’t match.

Here’s an example of the front end making an API call (this one creates a JSON or CSV export of stored data):

export const exportBookmarks = async (user: User, format: string) => {
// make a GET request to the server to export all bookmarks
try {
const response = await fetch(`${API_BASE_URL}/${user.uid}/export/${format}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${user.access_token}`
}
});
if (response.status === 200) {
const data = await response.text();
return data;
}
}
catch (error) {
console.error('failed to export bookmarks', error);
}
};

And here’s the middleware that is inserted into the Express pipeline. If the method is “OPTIONS” it’s a CORS request and we can let it through, otherwise the check needs to be enforced:

export const authMiddleware = async (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (req.method === 'OPTIONS') {
next();
return;
}
const authHeader = req.headers.authorization;
if (!authHeader) {
res.status(401).send({ error: 'Unauthorized - no header' });
return;
}
const token = authHeader.split(' ')[1];
if (!token) {
res.status(401).send({ error: 'Unauthorized - no token' });
return;
}
// verifyAndUnpack is a wrapper for the AWS aws-jwt-verify package
const result = await verifyAndUnpack(token, 'access');
if (result) {
if (req.params.user !== result.username) {
res.status(403).send({ error: 'Unauthorized: bearer is not the requested user' });
return;
}
next();
}
else {
res.status(401).send({ error: 'Unauthorized - token invalid' });
}
};

Summary

AWS Cognito is very flexible and provides a customisable account creation and sign up UI out of the box. OAuth2 authentication flows are not trivial to implement and neither are front end token management flows, but this is the simplest approach I’ve found so far.

--

--

Gareth Cronin

Technology leader in Auckland, New Zealand: start-up founder, father of two, maker of t-shirts and small software products