<onWebFocus />

Knowledge is only real when shared.

Rolling a Better Authentication

October 13, 2024

Improving and integrating the iltio.com authentication service.

This post discusses various topics related to authentication. In a previous post, I introduced the iltio.com authentication service. I'll explain what JSON Web Token is and how I added it to iltio, in addition to the less performant webhook authorization method. Nowadays, people want their data encrypted, but there is no way to store encrypted data in an external service if the encryption happens on the server. To resolve this issue, I'm adding client-side encryption and the necessary UI helpers, namely the iltio plugin, to make integration into any client effortless.

Additionally, this post will cover the migration from MySQL to Drizzle ORM and how another essential Backendless service—logging—was added.

Authentication can be incredibly complicated, and this complexity often impacts the user, who has to go through various steps just to authenticate. Currently, only authentication through email or phone is possible, but in this post, I'm evaluating other simpler, cheaper, and more futuristic verification methods. Lastly, I'll present a new PWA I'm launching that uses client-side encryption provided by iltio. That way, you won't have to worry about me reading any of your things.

What is iltio.com?

This Backendless service aims to provide seamless integration of authentication into any web-based application. It is Backendless, as it can be directly integrated into any application without the need for a database or changes to the server.

It allows your users to register or log in using the same form, with verification possible through email or phone. Authentication does not require any redirects, and the login flow is fully customizable using forms available for any framework, making it easy to integrate into most applications.

Cryptographic Authorization with JWT

One the user logs in to an application through iltio they'll receive a token that can then be used by other services to authorize through a webhook. The problem with this approach is that each time a resource is accessed on a service quite a bit of time is lost to call the webhook. Luckily, there is a safe cryptographic way that you can use to allow your users to connect to services without the need to call the webhook again. When caching is implemented the webhook method will only require the server to call the hook once for the same token, but still there has to be a caching mechanism on the server (another database or table) and initially a call is always required leading to worse latency when the user first tries the application.

This technique is called JSON Web Token. When the user authenticates with iltio an encrypted token containing information communicated to the service without there being any option for the user to read or taper with the token. The token can only be used to access resources granted by it and will automatically expire after a certain time.

The JWT can also be signed with an RSA algorithm. In this case anyone with access to the public key can read and verify tokens issued. This method has the added benefit that the server where the token is validated for resource access won't be able to issue any tokens themselves as this would require the private key.

{
  iat: 1723647384,
  exp: 1723683384,
  "https://hasura.io/jwt/claims": {
    "x-hasura-allowed-roles": [ "user" ],
    "x-hasura-default-role": "user",
    "x-hasura-user-id": "f280ad6c-4f5a-4db8-8594-2d74e043b7f0",
  }
}

The above is an example of a decrypted token. iat describes the time when the token has been issued and exp denotes the expiration date. In addition it contains hasura specific claims that can be used to give a user access to protected resources. iltio in this case will always add the role user and supply the uid with which you can ensure data stored in a table will only be accessible by the user themselves, which is a very common use case for many applications.

import { Algorithm, sign } from 'jsonwebtoken'

function createJsonWebToken(uid: string) {
  return sign({
      'https://hasura.io/jwt/claims': {
        'x-hasura-allowed-roles': ['user'],
        'x-hasura-default-role': 'user',
        'x-hasura-user-id': uid,
      },
    },
    'THIS_IS_THE_SECRET_KEY',
    {
      algorithm: 'HS256' as Algorithm, // 'RS512' for RSA with public key.
      expiresIn: '10h', // Format: https://github.com/zeit/ms.js
    })
}

Issuing a token is relatively straightforward using the jsonwebtoken package. The claims are in the first argument, the secret key in the second and the algorithm as well as the expiration date can be configured in the third.

All that's required on a resource service like Hasura it either a link to the webhook which can be set in the console with the HASURA_GRAPHQL_AUTH_HOOK environment variable to https://iltio.com/api/hasura or as the HASURA_GRAPHQL_JWT_SECRET which can be accessed after logging into iltio and enabling Hasura integration in the Integration tab.

RSA Public Key Algorithm

iltio also allows you to authorize using the RSA public key method. This works just as well and can be considered somewhat safer. To ensure your users will get a JWT signed using this method select public as the algorithm in the integration tab on iltio.com. This will also display the HASURA_GRAPHQL_JWT_SECRET value with the public key that you have to add as an environment variable on Hasura.

iltio Integration Interface

Screeshot of the Hasura integration interface.

While iltio will take care of creating the private and public keys as well as signing the JWT its relatively straightforward to do so yourself. To issue RSA keys yourself you first need to create RSA private and public key-pairs locally which you'll then place on your server.

ssh-keygen -t rsa -b 4096 -m PEM -E SHA512 -f jwtRS512.key # Don't add a passphrase.
openssl rsa -in jwtRS512.key -pubout -outform PEM -out jwtRS512.key.pub

The above command will create a file containing the private key and one with the public key. You need to private key to issue valid JWTs to users while the public key can be distributed to services where users want to authorize.

Adding JWT Verification to your Service

Adding a JWT authentication method to your service is apart from a simple webhook probably the easiest way to allow developers a way to verify a specific user is allowed to access a resource. If you want to make your application compatible with iltio you can use the jsonwebtoken package to verify tokens from users. The algorithm will automatically be detected based on the token and our options are part of the defaults.

import { verify } from 'jsonwebtoken'

const claims = verify(token, 'THIS_IS_THE_SECRET_KEY')

Backendless Privacy

When it comes to user data privacy, two issues arise. The first is on the login provider's side, as there is no way to ensure the provider will not be able to log in as any user on your platform. Even if you host the authentication yourself, the verification provider can still bypass the authentication. The second issue is that the provider storing your data will have access to it in clear text, even if it is later stored in an encrypted format.

With iltio.com, I'm trying to solve both of these issues by adding optionalclient-side encryption to guarantee full user privacy wherever possible. This is achieved through the iltio plugin, which offers helpers to encrypt and decrypt all data sent to the server in requests. This way, the data remains private as the key is only stored on the client. All data that arrives on the server is already encrypted. Obviously, this will not work for some types of data that need processing on the server. However, this should only be done with non-sensitive fields, and in a Backendless architecture, this will rarely be needed anyway.

import { Encryption, useEncryption } from 'iltio'

const PromptUserEncryption = () => {
  <Encrypt />
}

The Encrypt component serves multiple purposes. First, it allows the user to generate a client-side encryption key used to encrypt any sensitive data sent to the backend. Once encryption is enabled, the key is stored on the client, and the user should also back it up somewhere. On the server side, the iltio.com authentication service will flag the user as having enabled encryption. The Encrypt component then allows the user to remove the stored encryption key (without logging out) or disable encryption altogether. If the key is removed, Encrypt will prompt the user to re-enter the key. Additionally, when encryption is enabled, the Encrypt component will automatically appear as an additional step in the login flow.

To ensure the Encrypt component is displayed when appropriate, there is also a ReactuseEncryption hook that returns the current state. When the user has encryption enabled, the Encrypt component will automatically be shown after successful authentication.

import { encrypt } from 'iltio'

const data = { id: 123, message: 'Keep this secret!' }
const encrypted = encrypt(data, { ignoreKeys: ['id'] }) // { id: 123, message: 'yrhfkadshfgksdlhfsduafhdsuayk' }

await fetch('/api/data', { method: 'POST', body: JSON.stringify(data) })

Using the encrypt method, it is possible to quickly encrypt any data before it is sent to the server. The encrypt method will recursively traverse all JSON-compatible data types and encrypt any value, except those under properties listed as the second argument.

Storing the Encryption Key

Since the encryption key must be stored on the client, it's necessary to use one of the available methods for secure storage. Once the key is lost, the data cannot be recovered, so it's essential to ensure the user can easily and safely store their key. The simplest method is to ask the user to write it down or save it somewhere. The drawback of this method is the lengthy retrieval process and the need for manual typing of the key.

Some people may fail to store their encryption key safely. Therefore, for the future, especially if your application is encrypting sensitive information that cannot be lost, I am considering adding recovery methods to retrieve a lost key. However, with client-side encryption only, this can be much more difficult.

Validating the Encryption Key

When the user encrypts their data and logs in again, they will be prompted for their encryption key. Since the encryption key is never stored on the server, it's difficult to verify and display to the user whether they have entered the correct key. To achieve this essential validation, an encrypted version of the text "Hello Encryption" is sent to the server and stored there. Although the clear text is known, there is no way for the server to guess the encryption key from this. However, on the client side, the login process can attempt to decrypt the message using the key entered by the user and validate it this way.

Encrypting and Decrypting Existing Data

If encryption is enabled when the user already has unencrypted data stored on the server, it won't automatically be encrypted. Retroactively encrypting data already sent to the server in clear text doesn't make much sense. Therefore, only new additions or updates to old data will be encrypted.

What if a user turns encryption off? In this case, the user no longer needs privacy, and it is safe to send the encryption key to the server, where everything can be decrypted in one go.

Understanding the Authentication Flow

HTTP routes are usually used in a specific flow similar to a state machine. The existing interface documentation makes the login flow quite difficult to understand. To address this, I've created a new graphic that should greatly simplify the quick understanding of the authentication flow.

iltio Authentication Flow

Chart of the authentication flow.

Since encryption is meant to happen on the client, there is only one new route that will inform the service whether a certain user has encryption enabled on their account. When using any of the provided plugins for various frontend frameworks (encryption is currently only supported for React), this flow is already implemented and should work out-of-the-box.

Migrating MySQL to Drizzle ORM

Previously, I hosted the database on PlanetScale using MySQL, whose syntax I still remembered well from my old PHP days. With PlanetScale removing their free tier this month (and the application not yet being commercial), it was time to move to another database provider. Neon is offering free Postgres databases, which are perfectly suited to work with Vercel serverless functions.

While the differences between MySQL and PostgreSQL syntax are negligible, it was time to move to a serious ORM solution. Drizzle recently came up, and according to the Drizzle ORM in 100 Seconds video by Fireship, it should be fairly straightforward to use.

An ORM (Object-Relational Mapping) is an abstraction layer between the data accessed in the code and the actual data stored in the database. Unlike a database UI like Hasura, you still have to manually define the database schemas in code. However, once that's done, you'll get full TypeScript support when accessing your data through Drizzle. One of the most obvious tasks to assign to an LLM (Large Language Model) is converting an old MySQL migration to a Drizzle schema. However, since this plugin is so new, this is not something ChatGPT can do for you yet. The Drizzle documentation also does not offer a way to convert old schemas to match their syntax, so you're left to read the documentation and do it yourself. One advantage of Drizzle is that it supports nearly any database and plugin, as well as any SQL syntax. At first, this can be somewhat overwhelming until you identify the parts you actually need, which for an ORM are few.

export const insertTokenForUser = async (userId: number, token: string) => {
  const db = await connect()
  await db.insert(schema.tokens).values({ userId, token })
}

While Drizzle already looks very polished, it still has some rough edges. For example, it has a practical Drizzle Studio that can be used as a client to view the database. However, each time the schema is updated or the database URL changes, I've had to restart macOS for any changes, which seem to be cached somewhere, to be reflected. Shellscape on 𝕏 has also mentioned that Drizzle Kit, which is responsible for migrations as well as the studio, isn't yet open source and has some glaring bugs. It's too early to say whether this will become a problem, as Drizzle itself is still fairly new and there seems to be a dedicated team of maintainers working on Drizzle and the Kit. Drizzle has managed to find several database providers—Turso, Xata, and Neon—that sponsor their project.

Adding Backendless Logging

The EPIC Framework is a frontend framework I'm developing, which I have previously mentioned. The framework is guided by the idea of a Backendless future, in contrast to most current frameworks that are reverting to a server-driven architecture. However, this post isn't intended to go into the reasons why Backendless makes sense, as this is simply implied for now.

iltio Logs Interface

Screeshot of the current logging UI.

iltio.com aims to complement Backendless implementations by providing simple interfaces that can be directly accessed from the client. Authentication is probably the most obvious service that can be provided externally for any application. Another essential service is logging, also referred to as observability or analytics. The idea is to register events that can then be called either from the client or a Serverless function to log specific activities. While anything can be logged, it's best to log as little as possible. Good examples include logging a new paying customer signing up or an unexpected error occurring. Whenever you log in to iltio.com, you'll get an overview of all the events that have been logged recently. This is an essential way to stay on top of how your application is performing. In the future, you'll also be able to receive notifications for specific events, groups of events, or even more custom triggers.

import { log } from 'iltio'

await log('My log message!', 123456789)

Logging itself is pretty straightforward. Once an event is set up, you can send logs through the log helper or make an HTTP POST call to the iltio log API. To log something, you'll need the message as the first argument and the event ID as the second. You receive an event ID after creating an event in the iltio interface.

export default function middleware(request: Request, context: RequestContext) {
  context.waitUntil(log('Message from middleware!', 'my-middleware-event'))
  return next();
}

Exploring Further Verification Methods

One of the most effective ways to prevent abuse on your platform is through solid verification methods. Verification only needs to happen once during registration. Iltio currently offers registration via email or phone. Phone verification provides a certain guarantee, as in most countries, you need to identify yourself to obtain a number. If you commit a crime on the platform, your number can be reported to the police and traced back to you. This is not the case with email. However, phone number verification through text message can be quite costly, especially when also used to authenticate users upon each subsequent login.

Unlike in everyday life, there is a distinct culture of privacy on the internet. This allows bots to proliferate on social media platforms, where the problem is most obvious. The threat of possible punishment is often enough to deter abuse from a platform, yet most platforms haven't implemented solid verification methods. Often, AI is used to detect automated behavior, but this approach hasn't been very successful, as bots simply use AI to mimic human behavior.

What further verification methods are there? On 𝕏, Elon Musk recently implemented verification through a small cash payment. This can effectively combat bots, as most are used to cheaply distribute advertisements, and as soon as that is no longer cheap, they will disappear. World ID is a cryptographic approach that scans people's biometric data with a dedicated physical device. While this cryptographic approach guarantees that users cannot be traced back, it ensures that each registering user is a unique human. This can prevent spam or keep it low enough to be manageable. When an application is specific to a country, something like SwissID is also feasible, where the provider ensures that the user's ID is verified.

Integrating Authentication and Client-Side Encryption into a PWA

TODO describe practical use case.

This post was revised with ChatGPT a Large Language Model.