Part 6. Authentication with JWT, JSON Web Token
In this part of the tutorial, we’ll integrate JSON Web Token, or JWT, to our Node.js app and use it for user authentication. On the diagram above, I outlined the main use case for using JWT. User authenticates and receives a JWT. Only Auth Service has a Private Key, and uses it to create a JWT with a jwt.sign(), which will include any payload (userId in our case). jwt.sign() also sets up a JWT expiration time. No backend services are required to persist JWT; it is a client’s responsibility. Any other service, when it receives a request from a user with a JWT, verifies the JWT with jwt.verify(), and extracts the payload (userId) using a Public Key. userId can be used for further requests processing.
The information, or payload, passed in JWT token is not encrypted and only encoded with base64. But it can’t be modified along the way as the signature is calculated using the payload, and the singuture, token itself, becomes invalid if the payload is altered along the way. There will be an example in the end.
In the previous part of the tutorial, we implemented POST /api/v1/user request which creates a user in MongoDB. If you don’t follow the tutorial, you can get the sources and use them as a start point:
$ git clone https://github.com/losikov/api-example.git
$ cd api-example
$ git checkout tags/v5.0.0
$ yarn install
Install jsonwebtoken npm:
$ yarn add jsonwebtoken @types/jsonwebtoken
Private/Public Keys
We need to generate a private and public key pair. It is ok to store them in git for development and test purposes, but for production deployment, you might need another strategy. Also, only authentication/login service needs a private key to generate JWT, while other services need public key only. In this example, there’s a single code base, and no split between services. But, private key will be used to generate JWT, and public key to verify/decode it.
Private key can be used for both, generating and verifying the token. Instead of a private key you can use just a random secret value.
To generate the keys, run the following commands. It will ask you for a passphrase. In the example, I used “PEMPassPhrase”.
$ mkdir config/jwt
$ openssl genpkey -algorithm RSA -aes256 -out config/jwt/private.pem
$ openssl rsa -in config/jwt/private.pem -pubout -outform PEM -out config/jwt/public.pem
Let’s add the file paths and a passphrase to env files. Update the schema file, config/.env.schema, add:
PRIVATE_KEY_FILE=
PRIVATE_KEY_PASSPHRASE=
PUBLIC_KEY_FILE=
To the file with default values, config/.env.defaults, add:
PRIVATE_KEY_FILE=./config/jwt/private.pem
PRIVATE_KEY_PASSPHRASE=PEMPassPhrase
PUBLIC_KEY_FILE=./config/jwt/public.pem
For the convenience, to read the variables easily, update src/config/index.ts file. Add the variables to Config interface, and to config constant:
Generate JWT
Update src/api/services/user.ts imports and file header to read private and public key files:
Check the options, line 18–21 which will be used for a token generation, and line 24–26 which will be used for token verification.
To the same file, add createAuthToken and login functions. Include them to the default export (line 41 below):
login function from the code snippet above accepts login and password as arguments. It finds the user in DB (line 19), verifies the password (line 24), and finally calls createAuthToken (line 29). It returns a user id, a JWT, and an expiration time.
createAuthToken generates a token with a single jwt.sing() call (line 3). userId is passed as a payload. The generated JWT has an expiration time specified in signOptions, 14 days. But, I get it programmatically in lines 5–7, to pass it back to a user and to cache the tokens which will be described in the part 8.
sign() callback doesn’t return the expiration time; decode JWT in order to get the expiration time is CPU intensive operation
Let’s cover login service and token generation with unit tests. Create src/tests folder and user.ts helper file in it with the following content:
dummy function returns a fake email, password, and name.
createDummy creates a fake user and saves it to database.
createDummyAndAuthorize creates a fake user, saves it to a database, and generates JWT with the user id.
Finally, update src/api/services/__tests__/user.ts import createDummy from the file we’ve just created, and add tests for login:
If you run unit tests with the --coverage flag, we’ll see that we don’t cover jwt.sign() failure callback:
There’re might be some elegant ways to make jwt.sign to fail (call callback with an error), but I like a simple and straightforward solution. Create src/api/services/__tests__/user_failure.ts file with the following content:
If you run yarn unit:tests --coverage now, you will see that src/api/services/user.ts is covered 100%.
Verify JWT
Update auth function in src/api/services/user.ts to call jwt.verify:
Update auth unit tests in src/api/services/__tests__/user.ts:
If you run all unit tests now, you will see that 'should return 200 & valid response to authorization with fakeToken request' test fails as it uses fake token. Update src/api/controllers/__tests__/greeting.ts headers and the test to create a user authenticated with JWT, and check for a real user id. Don’t forget to connect to DB in beforeAll, and close the connection in afterAll:
Run unit tests, include the --coverage flag. All we need to do now is to implement a login request.
POST /api/v1/login
It is not a good practice to pass a password in a plain text. Use btoa/atob in client/server at least.
In config/openapi.yml, add /login section and add bearerFormat to bearerAuth section as below:
Implement login function in src/api/controllers/user.ts:
Add the import and describe(‘POST /api/v1/login’, …) to src/api/controllers/__tests__/user.ts:
In this example, I don’t add all extensive unit tests. To keep close to 100% coverage, add describe(‘login failure’, …) to src/api/controllers/__tests__/user_failure.ts:
We are good as of coverage now:
For production code, you should add more tests for all corner cases, and also you can add end to end unit tests calling a sequence of POST /api/v1/user, POST /api/v1/login, etc.
JWT Drawbacks
- Token size, around 1Kb. Keep in mind if your user requests are frequent, small, and simple, and you need high throughput and performance.
- CPU intensive. On my I9–9880H (MacBook Pro 16 2019), it can verify only ~16K tokens per second (auth method, code below). Keep in mind in your architecture that Node.js is a single core app and not designed for CPU consuming processing. You can scale it vertically though even on a single host with multiple cores, or use a cache for already verified tokens (next 2 parts).
Insert this code to src/api/services/__tests__/user.ts to try it yourself:
3. Not encrypted payload. The payload in JWT token is not encrypted, and libraries, like jsonwebtoken, don’t support it by default. For example, if we decode the token:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1ZjkwNzQ5NmRhYzBhYmE5MWM0ZDZiOTIiLCJpYXQiOjE2MDMzMDI1NTAsImV4cCI6MTYwNDUxMjE1MH0.KBaWPctCcIGjtlZCstxdG4xLCmJdQQ4dULWv9ThqawkvxW2VLnTAU3TL7nnYp1KHKTjRBplqbQricco_Lbxb_xAuWOO76zbbT6pG6_TzjePQq07nCO6v8CPAAyazPmuYF_NnQ-R5Ee6UxsDW1UYaUwZTXTwiLg3aexSDJ0TfBncKI8WgMT6GV52CoNdoxRL_N1Q7Vf5j27QlS6rzenfzBfrdmteEGwN0hPfXbRJUiRUyyn4KqbQ7iWWeCcGY88XlgaSHEO3Kd2KNMeglr8ZM0VilUCcoOuNybNTHSOUzu-deS_XE1wU9kAp3UDRjjPtAUiychFQ6N-tWK96q_2p1Aw
with base64, we’ll get:
{"alg":"RS256","typ":"JWT"}{"userId":"5f907496dac0aba91c4d6b92","iat":1603302550,"exp":1604512150}
¥rÐ hí¬·FãÂPCT-kýNÂKñ[eK0Ý2ûv)Ô¡ÊN4A¦ZB¸rËoÿÄ 8îúͶÓêºý<ãxô*Ó¹Â;«üð�ɬÏæüÙÐùD{¥1°5µQÁ×OvÅ ÉÑ7ÁÂñh O¡ç`¨5Ú1D¿ÍÕÕöí Rê¼ÞüÁ~·fµáÀÝ!=õÛD"EL²ªmâYgpf<ñy`i!Ä;rØ£Lz kñ4V)T ʸÜ51Ò9Lîù×ýq5ÁOdÔ
ã>Ð'!úÕ÷ª¿Ú@
If you don’t want your client to read JWT payload, you can encrypt the payload or only part of it using the same private/public key pair. In this case, only your servers can read it.
You can download the project sources from git https://github.com/losikov/api-example. Git commit history and tags are organized based on the parts.
In the next two parts of the tutorial, we’ll review the caching in Node.js, internal with node-cache and external with redis.