Hasura allows you to invoke any HTTP request as an Action. This action with a defined schema can then appear in your Hasura GraphQL Schema as a query or a mutation. Actions are very powerful since you can return data and stitch it back into the database.
Securing your endpoints can happen in a handful of ways. The most secure is utilizing VPC Peering. Essentially your code runs along side Hasura and cannot be reached from the outside world. This is not always an option for most people. That leaves 2 other options.
The first, is always passing the users token to your endpoint. With Actions you can tell Hasura to forward all headers. This actually works well, but also doesn't guarantee that the Action was invoked from Hasura. One down side is that executing from the Hasura console you can bypass the JWT header and execute an action with any session variables as any role. This means you would never be able to test out your Action endpoint without a JWT that matches up with the Hasura authentication you have provided.
One other solution is using a shared event secret. We can provide our code, and Hasura with the same exact key. With Actions you can apply additional headers onto each request. You can then verify each request is actually coming from Hasura you know and trust. Hasura will handle the auth and role based stuff, and your code can depend on it.
There are of course drawbacks to having shared secrets, but in a pinch when you cannot run all of your code next to each other it is still possible to ship code that can securely depend on Hasura.
We need to modify our docker-compose.yaml
and provide Hasura with a few more environment variables. We'll provide it a base URL for where our actions will be running. We specify that as http://host.docker.internal:3000/api
. So when we do setup our actions we only need to specify /whatever
.
Then we need to define our secret
ACTION_BASE_URL: http://host.docker.internal:3000/api EVENT_SECRET_KEY: super_secret_token
Setup a .env
for our API.
EVENT_SECRET_KEY=super_secret_token
To demonstrate our demo we'll setup an endpoint that just responds with Hello
. We can use local vercel dev
to spin this up.
In vercel.json
add in our function paths.
{ "functions": { "api/*.ts": { "memory": 1024, "maxDuration": 10 } } }
Next we'll create a file /api/hello.ts
. We'll receive a request, and just respond with Hello
.
import { VercelRequest, VercelResponse } from "@vercel/node"; const handler = (req: VercelRequest, res: VercelResponse) => { res.json({ res: "Hello", }); }; export default handler;
Open up our Hasura console and click on Actions
and then Create
.
Each action is a single bit of Schema that you define. It declares the query or mutations name, and then additionally what the response from your Http handler will be. So first we create a query called Hello
.
type Query { Hello: HelloOutput }
Next we declare our HelloOutput
which needs to match the response structure from the handler we created.
type HelloOutput { res: String! }
Then we need to specify what our URL is that should be invoked. We use our environment variable we set called ACTION_BASE_URL
which yields http://host.docker.internal:3000/api
. Then with templating syntax we can add /hello
.
{{ACTION_BASE_URL}}/hello
The final bit before creation is adding our event secret key. We need to specify the header to look for, and then specify that we want the value sent to come from an environment variable.
Once we click save we can go and test it out.
We can see it works, but we aren't yet verifying our secret key.
You can implement this as middleware in some systems, with any sort of vercel/nextjs serverless system there is less ways to define middleware. What we can do is create a wrapper that will accept a handler. The wrapper will check the request headers first, and if all is good then invoke the handler. Otherwise we will error out.
import { VercelRequest, VercelResponse, VercelApiHandler } from "@vercel/node"; export const validateVerificationKey = (handler: VercelApiHandler) => { return async (req: VercelRequest, res: VercelResponse) => { try { if ( !req.headers.verification_key || (req.headers.verification_key as string) !== process.env.EVENT_SECRET_KEY ) { throw new Error("Verification key does not match"); } await handler(req, res); } catch (error) { res.status(400).json({ message: error.message, code: error.message, }); } }; };
Then in our /api/hello.ts
file we import our validate function.
import { validateVerificationKey } from "../lib/validate";
Then we wrap our handler
with it, and we are good to go.
export default validateVerificationKey(handler);
If we visit our http://localhost:3000/api/hello
in our browser in which we do not provide our token we can see that we get an error.
Despite all the drawbacks of a shared secret, it does work and you should do your best to keep your secrets secured. I'd encourage you to attempt to remove any code that is crucial from being publicly accessible. However in some cases it is not important, or necessary and using a shared secret can get you shipped and secure.