Protecting REST Endpoints with JWTs: End-to-end Guide
Published
While researching how I could protect a REST endpoint using JSON Web Tokens (JWTs), I realized there wasn’t a cohesive guide easily searchable on Google.
After creating a proof of concept that signed and verified tokens using X.509 SSL Certificates, I thought it might be helpful to create some documentation around the different pieces.
Why JSON Web Tokens?
JSON Web Tokens are used in the example below to demonstrate access to protected endpoints (presented as bearer tokens). The beauty behind JWTs is that endpoints can verify tokens on their own, without requiring checks against an Authentication Server. For more information, see my article on Shared Authentication with JSON Web Tokens.
The example below demonstrates how X.509 SSL Certificates can be used to sign and verify JWT tokens without having to use a pre-shared private key; protected endpoints can retrieve the public certificate in an open matter, and use it when verifying tokens.
Overall Architecture
In our example, certain resources should only be accessible to authenticated users. Once users authenticate, they will be given a token that contains an expiration date and the URLs they are granted access to encoded into the payload (see this primer for more information on JWT structure). Tokens will be generated and signed using the private key from a X.509 SSL Certificate pair.
The user will then present the token to a Protected REST Endpoint to demonstrate they have been granted access by the Auth Server. The endpoint will verify the provided JWT token using the corresponding X.509 SSL Certificate public key. The SSL Certficate and public key will be loaded from a publicly accessible URL, and will not need to be pre-shared ahead of time.
A Look at the NodeJS Proof of Concept
A proof of concept was created to test the architecture above using an NodeJS Express server. The project has been posted to Github, and code snippets will be sampled below to help demonstrate features of the architecture.
1. Server Configuration and Initialization
In this example, the Authorization Server and the Protected Endpoint are running on the same host (and powered by the same code base). We will need to tell Express which public and private key certificates to use for JWT signing/verification.
This can be done in the .env
file in the root of the code repository:
# jwt signing certificate configuration
privateKeyPath=/local/file/system/path/privateKey.pem
publicKeyCertUrl=taylor.callsen.me
Once configured, the NodeJS Express server will read in the public and private keys once during start-up.
The private key certificate will be read from the local filesystem path specified above, and used for signing new tokens in the /routes/token.js
file:
const fs = require('fs')
const secretKey = fs.readFileSync(process.env.privateKeyPath, 'utf8')
console.log('cert secret key loaded from file path: ' + process.env.privateKeyPath)
The public key certificate will be downloaded from a public URL using the get-ssl-certificate
node module, and used in the /middlewares/verifyToken.js
file to verify tokens:
const sslCertificate = require('get-ssl-certificate')
let publicCert
sslCertificate.get(process.env.publicKeyCertUrl).then(function (certificate) {
publicCert = certificate.pemEncoded
console.log('cert public key loaded from url: ' + process.env.publicKeyCertUrl)
})
Once the server has been configured, start the NodeJS Express development server by navigating to the root code directory and execute:
npm install
npm start
If configured correctly, you should see the two console.log()
statements stating that the cert public and private keys have been loaded successfully.
2. User Requests Token
First the user needs to authenticate with the Authentication Server. In this example, no credentials are required, the user simply places a GET request to /token
.
The /token
endpoint uses the jsonwebtoken
node module from Auth0 to create and sign the token with the configured certificate’s private key. The token also includes an application specific payload, which in this case specifies which URL routes the user token has access to.
const express = require('express')
const router = express.Router()
const jwt = require('jsonwebtoken')
const fs = require('fs')
// load cert private key - executed at server start-up
const secretKey = fs.readFileSync(process.env.privateKeyPath, 'utf8')
console.log('cert secret key loaded from file path: ' + process.env.privateKeyPath)
// route: get token from certificate private key
router.get('/token', function(req, res, next) {
// payload defines access to specific routes (by URL path)
const payload = { access: [
'/token',
'/token/verify',
'/image',
// '/image/metadata'
]}
const token = jwt.sign(payload, secretKey, { algorithm: 'RS256', expiresIn: 60 * 10 })
res.json({ success: true, access_token: token })
})
Notice the /image/metadata
route is commented out from the payload access
array. We will see what happens when the user tries to access an unauthorized route in section 4 below.
3. User Presents Token to Protected Endpoint
Once the user has the access token, they can then present it as a bearer token Authorization header to the Protected Endpoint.
The /image
endpoint requires a valid JWT bearer token, otherwise it will return and HTTP 401 response. The example NodeJS Express server validates JWTs using a middleware function called verifyToken
:
const jwt = require('jsonwebtoken')
const sslCertificate = require('get-ssl-certificate')
// load public certificate key from url - excuted at server start-up
let publicCert
sslCertificate.get(process.env.publicKeyCertUrl).then(function (certificate) {
publicCert = certificate.pemEncoded
console.log('cert public key loaded from url: ' + process.env.publicKeyCertUrl)
})
// token verification middleware
const verifyToken = (req, res, next) => {
try {
// get bearer token
const authorizationHeader = req.headers['authorization']
const authorziation = authorizationHeader.split(' ')
const bearerToken = authorziation[1]
// confirm requested route is granted in access array of payload
jwt.verify(bearerToken, publicCert, { algorithm: 'RS256' }, (err, payload) => {
if (err) throw err
else {
if (payload.access.includes(req.originalUrl)) next()
else throw { message: 'requested url not granted in token' }
}
})
} catch (e) {
res.status(401).json({ success: false, status: 'failed to verify token', error: e })
}
}
The token is verified on line 21 using Auth0’s jsonwebtoken
node module, along with the configured public key certificate. Once the token is verified and decoded, the payload is examined to ensure the requested route is included in the access
array (line 24).
If all conditions are met, and the token is valid, the Express middleware calls the next()
function to proceed with processing the request.
Using the Verify Token Middleware
The verifyToken()
function shown above is then bound as middleware for all protected endpoints in the NodeJS Express app.
router.get('/metadata', verifyToken, function(req, res, next) {
res.setHeader('Content-Type', 'application/json')
res.sendFile(path.join(__dirname, '..', 'sample_data' , 'metadata.json'))
})
In the snippet above, the verifyToken()
function is passed as an argument into the router.get()
call. As a result, the verifyToken()
middleware function will be executed each time this route is requested. More examples of binding middleware are available in the Express documentation here.
4. User tries to Access Unauthorized Endpoint
In the event that the user tries to access an authorized endpoint (like the /image/metadata
route mentioned in section 2), the NodeJS Express server will return a 401 HTTP response to the client.
{
"success": false,
"status": "failed to verify token",
"error": {
"message": "requested url not granted in token"
}
}
In this example project, a 401 HTTP response can be returned for several reasons including:
- The client did not include a Bearer token in the Authorization header
- The supplied token is invalid, or expired (tokens are active for 10 minutes in this project)
- The token does not include the requested URL in the JWT token payload
access
array – this customization demonstrates how token payloads can be used to grant access to specific URLs
Further Reading
While this proof of concept was fun to create, there are a few next steps I’d take before implementing this functionality into any platform:
- Adjust the service to read X.509 SSL Certificates from a certificate manager, possibly AWS Certificate Manager
- Incorporate x5u or x5c token headers so that downstream endpoints know which certificate or key to verify tokens against
- Review the security concerns related to JWTs to hopefully prevent issues
Comments
No responses yet