Openstatus www.openstatus.dev

blog: how to secure your api routes with unkey (#335)

* wip:

* wip: blog

* chore: improve blog copy and styles

* chore: blog copy

* chore: include param

authored by

Maximilian Kaske and committed by
GitHub
c26cf5db c8c35016

+237 -71
apps/web/public/assets/posts/secure-api-with-unkey/unkey-landing.png

This is a binary file and will not be displayed.

+209
apps/web/src/content/posts/secure-api-with-unkey.mdx
··· 1 + --- 2 + title: How to secure your API with Unkey and Hono.js Middleware 3 + description: The simplest way to secure your API routes within seconds. 4 + author: 5 + name: Maximilian Kaske 6 + url: https://twitter.com/mxkaske 7 + publishedAt: 2023-10-01 8 + image: /assets/posts/secure-api-with-unkey/unkey-landing.png 9 + --- 10 + 11 + ## Introduction 12 + 13 + Why do we need to secure our APIs? Well, there are many reasons for that. The 14 + most important one is that we want to protect your data from unauthorized 15 + access. 16 + 17 + We will learn how to secure our [Hono.js](https://hono.dev) API server with 18 + [Unkey](https://unkey.dev). 19 + 20 + Unkey is a service that allows you to create and manage API keys. It includes 21 + useful features like: 22 + 23 + - **Rate limiting**: avoid getting DDoSed 24 + - **Temporary keys**: when working on free trials 25 + - **Key limitation**: limit the number of max. requests 26 + 27 + **The best part**: We have no additional database migration or setup to do. Just 28 + use the Unkey API and/or SDK to create, revoke and verify keys. 29 + 30 + Hono is similar to express just hipper and runs on the edge. 31 + 32 + ### How does OpenStatus use Unkey? 33 + 34 + Whenever OpenStatus creates an API key, we will send a request to the Unkey API 35 + using their [Typescript SDK](https://docs.unkey.dev/libraries/js/overview) with 36 + the a specific `ownerId` which will be the `workspaceId` in our case. The user 37 + will get an API key back which they can use to access their content via our API 38 + route. Unkey will match the API key to the `ownerId` and we will be able to 39 + validate that the request is the owner of the `workspaceId`. 40 + 41 + As an example, here the Next.js `server action` (see 42 + [GitHub](<https://github.com/openstatusHQ/openstatus/blob/main/apps/web/src/app/app/(dashboard)/%5BworkspaceSlug%5D/settings/_components/api-keys/actions.ts>)) 43 + to allow users to create and revoke their own API keys: 44 + 45 + ```ts title="actions.ts" 46 + "use server"; 47 + 48 + import { Unkey } from "@unkey/api"; 49 + 50 + const unkey = new Unkey({ token: process.env.UNKEY_TOKEN }); 51 + 52 + export async function create(ownerId: number) { 53 + const key = await unkey.keys.create({ 54 + apiId: env.UNKEY_API_ID, 55 + ownerId: String(ownerId), // workspaceId 56 + prefix: "os", // os_1234567890 57 + // include more options like 'ratelimit', 'expires', 'remaining' 58 + }); 59 + return key; 60 + } 61 + 62 + export async function revoke(keyId: string) { 63 + const res = await unkey.keys.revoke({ keyId }); 64 + return res; 65 + } 66 + ``` 67 + 68 + To test key creation, you can simply go to the 69 + [Unkey Dashboard](http://unkey.dev/app) and create an API key manually instead 70 + of using the SDK. The SDK is useful once you want your users to create API keys 71 + programmatically. 72 + 73 + ## Getting started 74 + 75 + Checkout [hono.dev](https://hono.dev) if you want to set up a new project or 76 + follow along if you already have a Hono.js project. 77 + 78 + We will pinpoint the most important parts of the setup. You can find the full 79 + code source on 80 + [GitHub](https://github.com/openstatusHQ/openstatus/tree/main/apps/server). 81 + 82 + ### Create the base path 83 + 84 + That's as simple as it looks. Create a `new Hono()` instance and define the 85 + routes (`route`) and middlewares (`use`). 86 + 87 + For the sake of this example, we only consider the `/api/v1/monitor` route. 88 + 89 + ```ts title="index.ts" 90 + import { middleware } from "./middleware"; 91 + import { monitorApi } from "./monitor"; 92 + 93 + export type Variables = { workspaceId: string }; // Context 94 + 95 + const api = new Hono<{ Variables: Variables }>().basePath("/api/v1"); 96 + 97 + api.use("/*", middleware); 98 + api.route("/monitor", monitorApi); 99 + 100 + export default app; 101 + ``` 102 + 103 + ### Create the middleware 104 + 105 + The middleware will automatically be applied to all routes that match the path 106 + `/api/v1/*`. We will use the `x-openstatus-key` request header to append the API 107 + key and verify it on our server. 108 + 109 + The Hono [Context](https://hono.dev/api/context) will be used to store the 110 + `workspaceId` we are retrieving from Unkey and sharing it across the 111 + application. 112 + 113 + Here, we are verifying the API key via the 114 + [`@unkey/api`](https://docs.unkey.dev/libraries/js/overview) package. It returns 115 + either an `error` or the `result.valid` whether or not to grant access to the 116 + user. 117 + 118 + ```ts title="middleware.ts" 119 + import { verifyKey } from "@unkey/api"; 120 + import type { Context, Next } from "hono"; 121 + 122 + import type { Variables } from "./index"; 123 + 124 + export async function middleware( 125 + c: Context<{ Variables: Variables }, "/api/v1/*">, 126 + next: Next, 127 + ) { 128 + const key = c.req.header("x-openstatus-key"); 129 + 130 + if (!key) return c.text("Unauthorized", 401); 131 + 132 + const { error, result } = await verifyKey(key); 133 + 134 + // up to you if you want to pass the actual message to your users 135 + // or simply return "Internal Server Error" 136 + if (error) return c.text(error.message, 500); 137 + if (!result.valid) return c.text("Unauthorized", 401); 138 + 139 + c.set("workspaceId", result.ownerId); 140 + 141 + await next(); 142 + } 143 + ``` 144 + 145 + ### Create the route 146 + 147 + Every route, here `monitorApi`, will have access to the `workspaceId` via the 148 + Context and therefore can query the database for the workspace. 149 + 150 + ```ts title="monitor.ts" 151 + import type { Variables } from "./index"; 152 + 153 + export const monitorApi = new Hono<{ Variables: Variables }>(); 154 + 155 + monitorApi.get("/:id", async (c) => { 156 + const workspaceId = c.get("workspaceId"); 157 + const { id } = c.req.valid("param"); 158 + 159 + // ...fetch data from your database [e.g. via Drizzle ORM] 160 + const monitor = await db 161 + .select() 162 + .from(monitor) 163 + .where( 164 + and( 165 + eq(monitor.id, Number(id)), 166 + eq(monitor.workspaceId, Number(workspaceId)), 167 + ), 168 + ) 169 + .get(); 170 + 171 + return c.json(monitor); 172 + }); 173 + ``` 174 + 175 + Read more about the Hono path parameter `":id"` 176 + [here](https://hono.dev/api/routing#path-parameter). 177 + 178 + ### Test it 179 + 180 + Once your project is running, you can test your implementation with the 181 + following `curl` command to access your monitor with the id `1`: 182 + 183 + ```bash 184 + curl --location 'http://localhost:3000/api/v1/monitor/1' \ 185 + --header 'x-openstatus-key: os_1234567890' 186 + ``` 187 + 188 + For OpenStatus, we are running our Hono.js server on [fly.io](https://fly.io) 189 + with [bun](https://bun.sh) via `bun run src/index.ts`. 190 + 191 + > We have included the 192 + > [`@hono/zod-openapi`](https://github.com/honojs/middleware/tree/main/packages/zod-openapi) 193 + > plugin to generate an OpenAPI spec out of the box. Read more about the 194 + > supported endpoints in our 195 + > [docs](https://docs.openstatus.dev/rest-api/openapi). 196 + 197 + ## Conclusion 198 + 199 + Et voilà. We have secured our API with Unkey and the Hono.js middleware, only 200 + allowing authorized users to access their data. 201 + 202 + Unkey increases our velocity and **helps us focus on what's relevant** to the 203 + user, not the infrastructure behind it. We also get **key verification 204 + insights** out of the box and can target specific users based on their usage. 205 + 206 + [@chronark\_](http://twitter.com/chronark_) has recently published an 207 + [`@unkey/hono`](https://docs.unkey.dev/libraries/ts/hono) package that uses a 208 + similar implementation under the hood, reducing some boilerplate code for you. 209 + Highly recommend checking it out if you are using Hono.js.
+6 -9
apps/web/src/contentlayer/plugins/pretty-code.ts
··· 1 1 import rehypePrettyCode from "rehype-pretty-code"; 2 2 import type * as unified from "unified"; 3 3 4 + // props to https://rehype-pretty-code.netlify.app/ 5 + 4 6 const prettyCode: unified.Pluggable<any[]> = [ 5 7 rehypePrettyCode, 6 8 { 7 - // prepacked themes 8 - // https://github.com/shikijs/shiki/blob/main/docs/themes.md 9 9 theme: "github-light", 10 - 11 - // https://stackoverflow.com/questions/76549262/onvisithighlightedline-cannot-push-classname-using-rehype-pretty-code 12 - // FIXME: maybe properly type this 13 10 onVisitLine(node: any) { 14 11 // Prevent lines from collapsing in `display: grid` mode, and 15 12 // allow empty lines to be copy/pasted 16 13 if (node.children.length === 0) { 17 14 node.children = [{ type: "text", value: " " }]; 18 15 } 19 - node.properties.className = ["line"]; // add 'line' class to each line in the code block 20 16 }, 21 - 22 - // FIXME: maybe properly type this 23 17 onVisitHighlightedLine(node: any) { 24 - node.properties.className?.push("line--highlighted"); 18 + node.properties.className.push("highlighted"); 19 + }, 20 + onVisitHighlightedWord(node: any) { 21 + node.properties.className = ["word"]; 25 22 }, 26 23 }, 27 24 ];
+22 -62
apps/web/src/styles/syntax-highlighting.css
··· 1 - /* span entire width */ 2 - pre > code { 3 - display: grid; 4 - } 1 + @tailwind base; 2 + @tailwind components; 3 + @tailwind utilities; 5 4 6 - .prose pre { 7 - margin: 0; 8 - padding: 0; 9 - } 10 - 11 - div[data-rehype-pretty-code-fragment] { 12 - overflow: hidden; 13 - 14 - /* stylist preferences */ 15 - background-color: var(--muted-foreground); 16 - border-radius: 0.5rem; 17 - width: 100%; 18 - margin: 1rem 0; 5 + div[data-rehype-pretty-code-title] { 6 + @apply -mb-5 ml-4 font-mono text-sm; 19 7 } 20 8 21 - div[data-rehype-pretty-code-fragment] pre { 22 - overflow-x: auto; 23 - 24 - /* stylist preferences */ 25 - padding-top: 0.5rem; 26 - padding-bottom: 0.5rem; 27 - font-size: 0.875rem; 28 - line-height: 1.5rem; 29 - @apply text-xs sm:text-sm lg:text-base; 9 + div[data-rehype-pretty-code-fragment] code { 10 + display: grid; 11 + counter-reset: line; 30 12 } 31 13 32 - div[data-rehype-pretty-code-title] { 33 - /* stylistic preferences */ 34 - margin-bottom: 0.125rem; 35 - border-radius: 0.375rem; 36 - padding-left: 0.75rem; 37 - padding-right: 0.75rem; 38 - padding-top: 0.25rem; 39 - padding-bottom: 0.25rem; 40 - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 41 - "Liberation Mono", "Courier New", monospace; 42 - font-size: 0.75rem; 43 - line-height: 1rem; 14 + div[data-rehype-pretty-code-fragment] code .line { 15 + @apply border-l-2 border-transparent px-5 py-0; 44 16 } 45 17 46 - div[data-rehype-pretty-code-fragment] .line { 47 - /* stylistic preferences */ 48 - padding-left: 0.5rem; 49 - padding-right: 0.75rem; 50 - 51 - border-left-width: 4px; 52 - border-left-color: transparent; 18 + div[data-rehype-pretty-code-fragment] code[data-line-numbers] .line::before { 19 + counter-increment: line; 20 + content: counter(line); 21 + @apply mr-8 inline-block w-4 text-right text-gray-500; 53 22 } 54 23 55 - div[data-rehype-pretty-code-fragment] .line--highlighted { 56 - border-left-color: hsl(var(--border)); 57 - background-color: hsl(var(--muted)); 24 + div[data-rehype-pretty-code-fragment] code .word { 25 + @apply rounded bg-gray-100/50 p-1; 58 26 } 59 27 60 - /* https://rehype-pretty-code.netlify.app/#:~:text=%60%60%60-,Line%20numbers,-CSS%20counters%20can */ 61 - pre > code[data-line-numbers] { 62 - counter-reset: lineNumber; 28 + div[data-rehype-pretty-code-fragment] code .highlighted { 29 + @apply border-l-2 border-gray-500 bg-gray-100/25; 63 30 } 64 31 65 - code[data-line-numbers] .line::before { 66 - counter-increment: lineNumber; 67 - content: counter(lineNumber); 68 - display: inline-block; 69 - text-align: right; 70 - color: hsl(var(--muted-foreground) / 0.5); 71 - 72 - /* stylistic preferences */ 73 - margin-right: 0.75rem; 74 - width: 1rem; 75 - } 32 + /* OVERWRITE prose-rem default */ 33 + /* div[data-rehype-pretty-code-fragment] pre { 34 + @apply px-0 py-5; 35 + } */