Skip to content

Ory Kratos: Headless Authentication, Identity and User Management

Authentication flows are quite common in the modern day software development. What we want from one authentication has a lot of overlapping funcionality with what our other applications need. Even across different industries, you can still see the same patterns apply when it comes to Identity and User Management.

Ory Kratos solves all that user management under one umbrella of identity server, providing a clean headless API that you can ship your own UI with. It empowers you to customize the frontend, while preserving the ever-common backend that is backed by the robust SQL database.

In this blog post, we will cover the introduction and basics of Ory Kratos, as well as the steps and guides to write your integration client.

If you've always wanted to stop reinventing the wheel, reduce code duplication and to follow security best practices, then Ory Kratos and this blog post is for you!

Kratos Auth

Over the entire course of my professional career, I have seen countless times where the application needed the same type and pattern of authentication protection.

A user signs up, logs in, logs out, and resets their password. The same idea, the same pattern, the same flow!

I have looked over different solutions and the way they handled their authentication system. Some are good, some are terrible, some are paid, some open-source.

This blog post is not here to tell you which to choose.

It's here to give you an idea of what Ory Kratos is, what problem it solves, and how you can use it to solve your own problem if you so choose to.

You should do your own research and decide for yourself whether or not what Ory Kratos provides fit your need; every problem's context is different and your technology stack may or may not be adaptable to Ory Kratos.

Who is this for?

The reality is, if you're reading this, you're likely an engineer of some sort. Perhaps an individual contributor, or maybe a decision maker somewhere down the organization hierarchy.

The guide to follow for the rest of this post is intuitive and easy to follow. If you've read any of the authentication RFCs, or worked with authentication systems before in anyway, you have a good understanding of what's about to come: what is and what isn't authentication that is.

I will mention the highlighting factors when it comes to theories, but this blog post is mainly hands-on with a lot of codes and examples.

Ory Kratos

We should set a clear objective of what we're trying to achieve here so that we can best prepare for the journey ahead.

The main goal is to write a frontend client for Ory Kratos so that the users of our application(s) can sign-up, sign-in, etc. The nature of the backend application (the app we are trying to protect behind authentication) doesn't matter here.

The user management and the database records of the identities will be kept on the Ory Kratos side and as such, any user-related authentication flow will be solely handled by Ory Kratos.

System Diagram
System Diagram

In a future post, we will learn how to protect the backend service in a private network and only send authenticated requests to the backend application using a combination of Ory Kratos1 and Ory Oathkeeper2.

Installation

This blog post does not make any assumptions on how you want to run Ory Kratos, nor its corresponding frontend code. That is your responsibility to figure out!

However, since for the purpose of demo we had to deploy both somewhere and somehow, the technology stack picked here is as follows. Feel free to adapt to your own stack:

  • The Kratos server has been installed as a Helm installation3 on a Kubernetes cluster. The Kratos public endpoints are exposed to the internet using the Gateway API4 and with the help of Cilium. We have guides in our archive for Kubernetes and Cilium installation if you need further help.
  • The source code for the frontend5 is written in pure Vanilla JavaScript, bundled with ViteJS6 and built with Bun7. I am by no means a frontender as you shall see shortly for yourself, however, the code is a Single Page Application without any JS framework; cause that's how Maximiliano Firtman8 taught the rest of us possible, among many disbeliefs!
  • The CI takes care of the deployment to the GitHub Pages9. Both are free for public repositories.

With that somewhat unconventional stack, let's see how we can create our own custom UI for the Ory Kratos10.

Kratos Configuration

The first step when it comes to Ory Kratos, is to have your config file ready. That is the starting point you should worry about before starting anything else.

There are many different attributes you can configure in Ory Kratos. Some of which are required fields, all of which defines the way you want Kratos to work for you.

In this blog post, we can't cover all the attributes and the combination of different values you can assign to them. However, we will cover the essentials to get you going. You won't have a hard time following the rest for yourself (they have a decent documentation11).

To get started, and to have a complete reference of all the available keys, you can copy the entire configuration from the official documentation12 and modify and customize it as you see fit. Below is the screenshot of how to do that.

Kratos Configuration Reference
Kratos Configuration Reference

The basic configuration that can kick things off looks something like this:

kratos/kratos-server-config.yml
identity:
  schemas:
    - id: users
      url: https://gist.githubusercontent.com/developer-friendly-bot/c3ce3c0c1ee6e4e706d3773ad7067132/raw/217a01ef02d78d29dd844461917bc22a616892f4/kratos.identity-schema.json
  default_schema_id: users
dsn: PLACEHOLDER
selfservice:
  default_browser_return_url: https://ory.developer-friendly.blog
  flows:
    logout:
      after:
        default_browser_return_url: https://ory.developer-friendly.blog
    registration:
      ui_url: https://ory.developer-friendly.blog/register
      enabled: true
    login:
      ui_url: https://ory.developer-friendly.blog/login
    verification:
      ui_url: https://ory.developer-friendly.blog/verify
      use: link
      enabled: true
    recovery:
      ui_url: https://ory.developer-friendly.blog/recovery
      use: link
      enabled: true
    error:
      ui_url: https://ory.developer-friendly.blog/
    settings:
      ui_url: https://ory.developer-friendly.blog/settings
  methods:
    link:
      config:
        base_url: https://auth.developer-friendly.blog
      enabled: true
    code:
      enabled: false
    password:
      enabled: true
  allowed_return_urls:
    - https://ory.developer-friendly.blog
courier:
  smtp:
    from_address: no-reply@developer-friendly.blog
    from_name: Developer Friendly
    connection_uri: PLACEHOLDER
serve:
  public:
    cors:
      allowed_origins:
        - https://ory.developer-friendly.blog
      enabled: true
    base_url: https://auth.developer-friendly.blog
    port: 4433
    request_log:
      disable_for_health: true
  admin:
    base_url: http://localhost:4434/
    port: 4434
    request_log:
      disable_for_health: true
log:
  format: json
  level: info
secrets:
  cookie:
    - ABCDEFGHIJKLMNOP
  cipher:
    - ABCDEFGHIJKLMNOPQRSTUVWXYZ012345
  default:
    - ABCDEFGHIJKLMNOP
cookies:
  path: /
  same_site: None
  domain: .developer-friendly.blog
session:
  lifespan: 1h
  cookie:
    path: /
    same_site: None
    domain: .developer-friendly.blog

There is a lot to uncover in this configuration file, and believe me there are many others trimmed for the sake of brevity.

But, let's explain some of the highlights from this file.

Identity Schema

The first section defines what type of schema you want to use. This "schema" is the definition of your users signing up and their attributes saved in the database. This will be an HTML form with all the fields you like filled by your users.

You can have more than one schema definition, which is a perfect use case for having different types of users, e.g., admin, employee, customer, etc.13

For our simple use case, there's only one.

You can pass the identity schema definition from either a file, a remote URL, or even a base64 encoded string. The choice is yours. However, keep in mind that readability matters and you have to be able to make sense of the schema by looking at it and base64 is not it!

In essence, the following identity schema JSON definition will result in the HTML form you see in the next screenshot.

{
  "$id": "default",
  "$schema": "http://json-schema.org/draft-07/schema#",
  "properties": {
    "traits": {
      "additionalProperties": false,
      "properties": {
        "company_name": {
          "default": "default",
          "title": "Company Name",
          "type": "string"
        },
        "email": {
          "examples": [
            "hi@developer-friendly.blog"
          ],
          "format": "email",
          "minLength": 3,
          "ory.sh/kratos": {
            "credentials": {
              "password": {
                "identifier": true
              }
            },
            "recovery": {
              "via": "email"
            },
            "verification": {
              "via": "email"
            }
          },
          "title": "E-Mail",
          "type": "string"
        },
        "first_name": {
          "title": "First Name",
          "type": "string"
        },
        "job_title": {
          "default": "Not Set",
          "title": "Job Title",
          "type": "string"
        },
        "last_name": {
          "title": "Last Name",
          "type": "string"
        }
      },
      "required": [
        "email"
      ],
      "type": "object"
    }
  },
  "title": "User",
  "type": "object"
}
HTML Sign Up Form
HTML Sign Up Form

UI URLs

This part of the configuration file defines the way you want Kratos to redirect requests to your frontend.

The path to your frontend endpoints are custom to your application; pick what's best for you. However, bear in mind that the Kratos server and your frontend application MUST be hosted under the same root domain.

The reason is for the cookies and the constraint around not being able to set a cookie from one domain to another; imagine if anyone could set a cookie on your domain from their own domain! 😱

This section of the configuration file specifies the domain you want to set cookies for. It is essentially THE key to keeping consistency and correctness between the Ory Kratos server and your frontend. Else, they won't work!

The key point to keep in mind, again, is to host both the frontend and the Kratos server under the same root-level domain. For example:

  • ✅ auth.example.com for Kratos and ui.example.com or example.com for the frontend are fine.
  • ❌ something.com for Kratos and something-else.com for frontend are not.

We will provide more details on the cookie and the domain later in this post.

Kratos Deployment

Skip this section if you have Kratos deployed elsewhere or are using the Ory Network14 with a paid plan. ✈

You don't have to self-host your Ory Kratos server if you don't want to. The Ory team provides a hosted version of Ory Kratos, as well as other Ory products on their Ory Network.

However, as of writing this blog post, they don't allow custom domains on their free version. Not having the same top-level root domain is a big no-no for Kratos and its UI and as such, we'll deploy the opensource version in our Kubernetes deployment.

If you need assistance setting up a Kubernetes cluster, follow one of our earlier guides. The main requirement, however, is that the cluster needs to be internet-facing.

We are using FluxCD CRDs here. If you're new to FluxCD, check out our earlier beginner's guide to get up to speed.

We are also using External Secrets Operator and cert-manager in this setup. We have guides on those as well, so feel free to check them out.

kratos/repository.yml
apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: HelmRepository
metadata:
  name: ory
spec:
  interval: 60m
  url: https://k8s.ory.sh/helm/charts

Kratos server is able to read the config file from the specified file, or from the environment variables15. Which is why we are capitalizing all the environments in the following ExternalSecret resource; remember all those values in our kratos/kratos-server-config.yml where we passed PLACEHOLDER as value!?

kratos/externalsecret.yml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: kratos-secrets
spec:
  data:
    - remoteRef:
        key: /kratos/dsn
      secretKey: DSN
    - remoteRef:
        key: /kratos/courier/smtp/connection-uri
      secretKey: COURIER_SMTP_CONNECTION_URI
    - remoteRef:
        key: /kratos/secrets/cookie/0
      secretKey: SECRETS_COOKIE_0
    - remoteRef:
        key: /kratos/secrets/cipher/0
      secretKey: SECRETS_CIPHER_0
    - remoteRef:
        key: /kratos/secrets/default/0
      secretKey: SECRETS_DEFAULT_0
  refreshInterval: 1h
  secretStoreRef:
    kind: ClusterSecretStore
    name: aws-parameter-store
  target:
    creationPolicy: Owner
    deletionPolicy: Retain
    immutable: false
    template:
      mergePolicy: Replace
      type: Opaque

The following Kustomization patches applied to the HelmRelease are just because of the lack of flexibility in the Ory Kratos' Helm chart. We have to manually pass some of the otherwise missing values.

kratos/release.yml
apiVersion: helm.toolkit.fluxcd.io/v2beta2
kind: HelmRelease
metadata:
  name: kratos
spec:
  chart:
    spec:
      chart: kratos
      sourceRef:
        kind: HelmRepository
        name: ory
      version: 0.42.x
  interval: 30m
  postRenderers:
    - kustomize:
        patches:
          - target:
              kind: Deployment
              name: kratos
            patch: |
              - op: add
                path: /spec/template/spec/initContainers/0/envFrom
                value:
                  - secretRef:
                      name: kratos-secrets
          - target:
              kind: Deployment
              name: kratos
            patch: |
              - op: replace
                path: /spec/template/spec/containers/0/args
                value:
                  - serve
                  - all
                  - --config
                  - /var/lib/kratos/config/config.yml
          - target:
              kind: StatefulSet
              name: kratos-courier
            patch: |
              - op: replace
                path: /spec/template/spec/containers/0/args
                value:
                  - courier
                  - watch
                  - --config
                  - /var/lib/kratos/config/config.yml
                  - --expose-metrics-port
                  - "4434"
  releaseName: kratos
  test:
    enable: false
  timeout: 5m
  upgrade:
    cleanupOnFail: true
    crds: CreateReplace
    remediation:
      remediateLastFailure: true
  values:
    deployment:
      extraVolumes:
        - name: kratos-config
          configMap:
            name: kratos-config
            items:
              - key: kratos-server-config.yml
                path: config.yml
      extraVolumeMounts:
        - name: kratos-config
          mountPath: /var/lib/kratos/config
          readOnly: true
    statefulSet:
      extraVolumes:
        - name: kratos-config
          configMap:
            name: kratos-config
            items:
              - key: kratos-server-config.yml
                path: config.yml
      extraVolumeMounts:
        - name: kratos-config
          mountPath: /var/lib/kratos/config
          readOnly: true
  valuesFrom:
    - kind: ConfigMap
      name: kratos-config
kratos/helm-values.yml
kratos:
  automigration:
    enabled: true
    type: initContainer
    customArgs:
      - migrate
      - sql
      - --read-from-env
      - --yes
      - --config
      - /var/lib/kratos/config/config.yml
deployment:
  environmentSecretsName: kratos-secrets
  automigration:
    customArgs:
      - migrate
      - sql
      - --read-from-env
      - --yes
      - --config
      - /var/lib/kratos/config/config.yml
statefulSet:
  environmentSecretsName: kratos-secrets
kratos/httproute.yml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: kratos
spec:
  hostnames:
    - auth.developer-friendly.blog
  parentRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: developer-friendly-blog
      namespace: cert-manager
      sectionName: https
  rules:
    - backendRefs:
        - kind: Service
          name: kratos-public
          port: 80
      filters:
        - responseHeaderModifier:
            set:
              - name: Strict-Transport-Security
                value: max-age=31536000; includeSubDomains; preload
          type: ResponseHeaderModifier
      matches:
        - path:
            type: PathPrefix
            value: /
kratos/kustomizeconfig.yml
---
nameReference:
  - kind: ConfigMap
    version: v1
    fieldSpecs:
      - path: spec/valuesFrom/name
        kind: HelmRelease
      - path: /spec/values/deployment/extraVolumes/configMap/name
        kind: HelmRelease
      - path: /spec/values/statefulSet/extraVolumes/configMap/name
        kind: HelmRelease
kratos/kustomization.yml
configurations:
  - kustomizeconfig.yml

configMapGenerator:
  - files:
      - values.yaml=./helm-values.yml
      - ./kratos-server-config.yml
    name: kratos-config

resources:
  - repository.yml
  - release.yml
  - externalsecret.yml
  - httproute.yml

Finally, we will create this stack as follow:

kratos/kustomize.yml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: kratos
  namespace: flux-system
spec:
  force: false
  interval: 5m
  path: ./kratos
  prune: true
  sourceRef:
    kind: GitRepository
    name: flux-system
  targetNamespace: default
  wait: false
kubectl apply -f kratos/kustomize.yml

This is all the Kubernetes knowledge we will need for this blog post. Promise! 🤞

That is to say, if you're not a Kubernetes guy, don't worry. All you need from this step, is an internet-accessible Ory Kratos server hosted under the same top-level domain as your UI frontend.

Moving forward, we will only work on JavaScript, HTML, and CSS. 🤓

Frontend Code

If you've been waiting for the UI part, this is it! 🎉

At this point, we will shift our focus to the frontend code; The custom UI for our Kratos server.

This will be the page that our users will see once they open the application. Everything else, is the communication between this frontend and the Ory Kratos.

To start things off, we will need a couple of unavoidable static files for template and styling.

NOTE: I am by no means a frontender. That is not my strong suit. Yet the following SPA gets the job done. Believe me, I have tested it! 😇 And, if you see any flaw with the code style, or something that you don't like, the repository of this website is publicly available and welcomes your pull requests. 🤗

HTML

The app's starting page is the following index.html file. It's simple, and that is its superpower. 🦸

frontend/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Ory Example - Developer Friendly</title>
    <link rel="stylesheet" href="/assets/styles.css" />
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer" />
    <script src="app.js" defer type="module"></script>
  </head>
  <body>
    <header>
      <nav>
        <ul>
          <li><a href="/">Home</a></li>
          <li><a href="/login">Login</a></li>
          <li><a href="/register">Register</a></li>
          <li><a href="/verify">Verify</a></li>
          <li><a href="/recovery">Recovery</a></li>
          <li><a href="/settings">Settings</a></li>
          <li><a href="/logout">Logout</a></li>
        </ul>
      </nav>
    </header>

    <main id="app"></main>

    <footer>
      <p>&copy; <a href="https://developer-friendly.blog" target="_blank">Developer Friendly</a>. All rights reserved.</p>
      <span><i class="fa-brands fa-github"></i><a href="https://github.com/developer-friendly/ory" target="_blank">Source Code</span></a>
    </footer>
  </body>
</html>

CSS

Beside the header, footer and the flex container for the form and table, there is nothing special about the styling either. 👇

Click to expand
frontend/assets/styles.css
:root {
  --primary-color: rgb(19, 18, 18);
  --secondary-color: #6c757d;
  --light-color: #f4f4f4;
  --danger-color: #dc3545;
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html,
body {
  height: 100%;
}

body {
  display: flex;
  flex-direction: column;
  font-family: Arial, sans-serif;
  line-height: 1.6;
  background-color: var(--light-color);
}

header {
  background: var(--primary-color);
  color: var(--light-color);
  padding: 1rem 0;
  text-align: center;
}

header nav ul {
  list-style: none;
  padding: 0;
  display: flex;
  justify-content: center;
}

header nav ul li {
  margin: 0 1rem;
}

header nav ul li a {
  color: var(--light-color);
  text-decoration: none;
  padding: 0.5rem 1rem;
}

header nav ul li a:hover {
  background-color: var(--primary-color);
  border-radius: 4px;
}

main {
  flex: 1;
  padding: 2rem;
  text-align: center;
}

h1 {
  margin-bottom: 1rem;
  color: var(--primary-color);
}

form,
table {
  max-width: 400px;
  margin: 0 auto;
  padding: 2rem;
  background: #fff;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
  border-radius: 8px;
  overflow-y: auto;
  max-height: 80vh;
}

form label {
  display: block;
  margin-bottom: 0.5rem;
  color: var(--primary-color);
}

form input[type="text"],
form input[type="email"],
form input[type="password"] {
  width: calc(100% - 22px);
  padding: 12px;
  margin-bottom: 1rem;
  border: 1px solid var(--primary-color);
  border-radius: 4px;
  font-size: 16px;
}

button {
  display: inline-block;
  padding: 10px 20px;
  font-size: 16px;
  font-weight: bold;
  color: var(--light-color);
  background-color: var(--secondary-color);
  border: none;
  border-radius: 5px;
  width: 100%;
  max-width: 300px;
  text-align: center;
  cursor: pointer;
  transition: background-color 0.3s ease, transform 0.2s ease;
}

button:hover {
  background-color: var(--primary-color);
  transform: scale(1.05);
  transition: transform 0.2s ease;
}

button:active {
  background-color: var(--primary-color);
  transform: scale(0.98);
}

button:focus {
  outline: none;
  box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.5);
}

button:disabled {
  background-color: var(--secondary-color);
  cursor: not-allowed;
}

footer {
  background: var(--primary-color);
  color: var(--light-color);
  text-align: center;
  padding: 1rem 0;
  position: fixed;
  width: 100%;
  bottom: 0;
}

footer p {
  margin: 0.5rem 0;
}

footer a {
  color: var(--light-color);
  text-decoration: none;
}

footer a:hover {
  text-decoration: underline;
}

@media (max-width: 600px) {
  header nav ul {
    flex-direction: column;
  }

  header nav ul li {
    margin: 0.5rem 0;
  }
}

.required {
  color: var(--danger-color);
}

i {
  margin-right: 0.3rem;
}

footer img {
  margin-right: 0.3rem;
}

Flow 101

Now the main business logic of the app we're trying to build.

But, before we jump into code, here's the diagram you should be aware of when it comes to Ory Kratos.

Ory Kratos and Frontend Flow
Ory Kratos and Frontend Flow

This diagram has all the steps to initiate and complete a flow in Ory Kratos. And be very mindful of the word flow here cause this is the most important thing Kratos understands and responds with.

Anytime you need something from Ory Kratos, it will find its way, one way or another, through a flow.

There are different types of flows in Ory Kratos. Six to be specific:

  • Login flow
  • Registration flow
  • Recovery flow
  • Verification flow
  • Settings flow
  • Logout flow

These flows are something you should be aware of, and implement the frontend counterpart for. The backend is already taken care of by Ory Kratos.

Although, fret not, these flows are quite similar in nature and a general piece of code can handle most, if not all.

Implement One Flow For All

Any application will start with registration and that is where we start our journey.

In the registration flow, we are asking Ory Kratos what it takes for us to start and submit a registration request and Ory Kratos will respond with the fields you need to submit by consulting the very same identity schema we passed it earlier.

If you know curl, this will be the registration flow in a nutshell:

cookie_jar=$(mktemp)
output="$(curl -sS --cookie $cookie_jar --cookie-jar $cookie_jar \
  -H "accept: application/json" \
  https://auth.developer-friendly.blog/self-service/registration/browser)"

csrf_token="$(echo $output | \
  jq -r '.ui.nodes[] |
    select(.attributes.name == "csrf_token") |
    .attributes.value')"
email=hi@developer-friendly.blog
password=123456

action=$(echo $output | jq -r '.ui.action')

curl -XPOST -sS --cookie $cookie_jar --cookie-jar $cookie_jar \
  -H "accept: application/json" \
  -H "content-type: application/json" \
  -d "{\"traits.email\":\"$email\",
       \"password\":\"$password\",
       \"csrf_token\":\"$csrf_token\",
       \"method\":\"password\"}" \
  $action

If that sounds too complicated to comprehend, don't worry. We'll break it down in our upcoming JavaScript code.

Remember the diagram we saw earlier between the frontend and the Ory Kratos? We will start by initiating a flow.

frontend/src/utils.js
import { kratosHost } from "./config.js";

var fetchOptions = {
  credentials: "include",
};


export async function initFlow(flow, extraHeaders = {}) {
  return await fetch(
    `${kratosHost}/self-service/${flow}/browser`,
    {
      ...fetchOptions,
      headers: {
        ...extraHeaders,
      },
    }
  );
}

export async function getFlowInfo(flowId) {
  return await fetch(`${kratosHost}/self-service/browser/flows?id=${flowId}`, {
    ...fetchOptions,
  });
}

Pay close attention that we are explicitly asking the fetch API to include the credentials in our call to the Ory Kratos server. Ignoring that will result in the required cookies not being sent to the server and your flow will never go pass the initial step! ⚠

frontend/src/flow.js
import { initFlow } from "./utils.js";


async function createForm(flowName) {
  var flowInfo, flowJson;

  var headers = {
    accept: "application/json",
  };

  flowInfo = await initFlow(flowName, headers);
  flowJson = await flowInfo.json();

  // build the HTML form from the json and return it

  return "TODO";
}

export default createForm;

Note the accept header we pass to the fetch API on line 8. This will make sure that the Kratos server responds with the JSON and won't redirect us to the same URL as we are in right now. Ignore doing so and it will result in double redirection to the current web address, which will nullify the origin header and you'll face a CORS error16.

At this point, we have the JSON response from the Kratos server. We have to use that information to dynamically create an HTML form to render for the user.

In order to be able to parse the JSON and render an HTML form from it, you have to know what type of JSON response you can expect from the Kratos server.

The JSON response from the registration flow looks something like the following. 👇 If you pay close attention, you will notice that the JSON response resembles a lot like the identity schema we passed to the Kratos server earlier.

Click to expand
{
  "id": "89b42a09-c821-453b-8207-e298a578a243",
  "type": "browser",
  "expires_at": "2024-05-19T14:32:09.717435811Z",
  "issued_at": "2024-05-19T13:32:09.717435811Z",
  "request_url": "https://auth.developer-friendly.blog/self-service/registration/browser",
  "ui": {
    "action": "https://auth.developer-friendly.blog/self-service/registration?flow=89b42a09-c821-453b-8207-e298a578a243",
    "method": "POST",
    "nodes": [
      {
        "type": "input",
        "group": "default",
        "attributes": {
          "name": "csrf_token",
          "type": "hidden",
          "value": "0eshsqW82u6puCW73HqPFuK/PO0VyCW3ZMcQwmMjMv21MuLCzbADsPx8BpDBuXpnyOFsA0LYzC1Kuk1z4+KFxA==",
          "required": true,
          "disabled": false,
          "node_type": "input"
        },
        "messages": [],
        "meta": {}
      },
      {
        "type": "input",
        "group": "password",
        "attributes": {
          "name": "traits.first_name",
          "type": "text",
          "disabled": false,
          "node_type": "input"
        },
        "messages": [],
        "meta": {
          "label": {
            "id": 1070002,
            "text": "First Name",
            "type": "info",
            "context": {
              "title": "First Name"
            }
          }
        }
      },
      {
        "type": "input",
        "group": "password",
        "attributes": {
          "name": "password",
          "type": "password",
          "required": true,
          "autocomplete": "new-password",
          "disabled": false,
          "node_type": "input"
        },
        "messages": [],
        "meta": {
          "label": {
            "id": 1070001,
            "text": "Password",
            "type": "info"
          }
        }
      },
      {
        "type": "input",
        "group": "password",
        "attributes": {
          "name": "traits.last_name",
          "type": "text",
          "disabled": false,
          "node_type": "input"
        },
        "messages": [],
        "meta": {
          "label": {
            "id": 1070002,
            "text": "Last Name",
            "type": "info",
            "context": {
              "title": "Last Name"
            }
          }
        }
      },
      {
        "type": "input",
        "group": "password",
        "attributes": {
          "name": "traits.email",
          "type": "email",
          "required": true,
          "autocomplete": "email",
          "disabled": false,
          "node_type": "input"
        },
        "messages": [],
        "meta": {
          "label": {
            "id": 1070002,
            "text": "E-Mail",
            "type": "info",
            "context": {
              "title": "E-Mail"
            }
          }
        }
      },
      {
        "type": "input",
        "group": "password",
        "attributes": {
          "name": "traits.company_name",
          "type": "text",
          "disabled": false,
          "node_type": "input"
        },
        "messages": [],
        "meta": {
          "label": {
            "id": 1070002,
            "text": "Company Name",
            "type": "info",
            "context": {
              "title": "Company Name"
            }
          }
        }
      },
      {
        "type": "input",
        "group": "password",
        "attributes": {
          "name": "traits.job_title",
          "type": "text",
          "disabled": false,
          "node_type": "input"
        },
        "messages": [],
        "meta": {
          "label": {
            "id": 1070002,
            "text": "Job Title",
            "type": "info",
            "context": {
              "title": "Job Title"
            }
          }
        }
      },
      {
        "type": "input",
        "group": "password",
        "attributes": {
          "name": "method",
          "type": "submit",
          "value": "password",
          "disabled": false,
          "node_type": "input"
        },
        "messages": [],
        "meta": {
          "label": {
            "id": 1040001,
            "text": "Sign up",
            "type": "info"
          }
        }
      }
    ]
  },
  "organization_id": null,
  "state": "choose_method"
}

By visualizing the JSON response, we can see that the .ui.nodes has all the fields we specified in our identity schema. We would only need to use this info to build the HTML form.

This step is a lot subjective and you can get very creative. Yet we simply create a bunch of inputs and labels inside an HTML form.

frontend/src/utils.js
import { kratosHost } from "./config.js";

var fetchOptions = {
  credentials: "include",
};

export async function initFlow(flow, extraHeaders = {}) {
  return await fetch(`${kratosHost}/self-service/${flow}/browser`, {
    ...fetchOptions,
    headers: {
      ...extraHeaders,
    },
  });
}

export async function getFlowInfo(flowId) {
  return await fetch(`${kratosHost}/self-service/browser/flows?id=${flowId}`, {
    ...fetchOptions,
  });
}

export function createFlowForm(flowJson, submitLabel = "Submit") {
  var form = document.createElement("form");

  form.action = flowJson.ui.action;
  form.method = flowJson.ui.method;

  var autofocus = false;

  var passwordField, passwordLabel;

  flowJson.ui.nodes.forEach(function parseNode(node) {
    if (node.type == "input") {
      var attr = node.attributes;
      var isSubmit = attr.type == "submit";
      var isPassword = attr.type == "password";
      var input = document.createElement("input");
      var label = document.createElement("label");

      if (isSubmit) {
        input = document.createElement("button");
      }

      if (node.meta && node.meta.label && node.meta.label.text) {
        label.innerText = node.meta.label.text;
      }
      input.type = attr.type;
      input.name = attr.name;
      input.value = attr.value || "";

      if (isSubmit) {
        var span = document.createElement("span");
        span.innerText = submitLabel;
        input.appendChild(span);
      }
      if (attr.required) {
        input.required = true;
        if (attr.type != "hidden") {
          var required = document.createElement("span");
          required.innerText = " *";
          required.className = "required";
          label.appendChild(required);
        }
      }
      if (attr.disabled) {
        input.disabled = true;
      }
      if (!isSubmit && !isPassword) {
        form.appendChild(label);
      }

      if (!autofocus && input.type != "hidden") {
        input.autofocus = true;
        autofocus = true;
      }

      if (!isPassword) {
        form.appendChild(input);
      } else {
        passwordField = input;
        passwordLabel = label;
      }
    }
  });

  if (passwordField) {
    form[form.length - 1].insertAdjacentElement("beforebegin", passwordLabel);
    form[form.length - 1].insertAdjacentElement("beforebegin", passwordField);
  }

  return form;
}

Not much is to say regarding the logic happening here. However, notice that we are intentionally deferring the creation of the password input until the very end. That is just a bit unfortunate since Kratos' JSON response does not send the orders of inputs as we'd like it; I would expect Kratos to do this out of the box!

frontend/src/flow.js
import { initFlow, getFlowInfo, createFlowForm } from "./utils.js";


async function createForm(flowId, flowName) {
  var flowInfo, flowJson;

  var headers = {
    accept: "application/json",
  };

  if (flowId) {
    flowInfo = await getFlowInfo(flowId);
  } else {
    flowInfo = await initFlow(flowName, headers);
  }

  flowJson = await flowInfo.json();

  var form = createFlowForm(flowJson);

  return form;
}

export default createForm;

We have most of what we need as far as JavaScript goes, yet there is still the entrypoint as well as the Vanilla JS router to take care of.

frontend/src/router.js
import CreateForm from "./flow.js";

var Router = {
  init: async function init_() {
    document.querySelectorAll("a").forEach((a) => {
      a.addEventListener("click", function overrideNavlinks(event) {
        event.preventDefault();
        var href = event.target.getAttribute("href");
        Router.go(href);
      });
    });

    window.addEventListener("popstate", (event) => {
      Router.go(event.state.route, false);
      return;
    });
    var route = location.pathname + location.search;
    await Router.go(route);
  },
  go: async function go_(route, addToHistory = true) {
    console.log("Navigating to", route);

    if (addToHistory) {
      history.pushState({ route }, "", route);
    }
    var pageElement;

    // In case the browser started the login somewhere else with the Kratos server
    var flowId = new URL(location.href).searchParams.get("flow");

    switch (true) {
      case route.startsWith("/login"):
        pageElement = await CreateForm(flowId, "login");
        break;
      case route.startsWith("/register"):
        pageElement = await CreateForm(flowId, "registration");
        break;
      case route.startsWith("/verify"):
        pageElement = await CreateForm(flowId, "verification");
        break;
      case route.startsWith("/recovery"):
        pageElement = await CreateForm(flowId, "recovery");
        break;
      case route.startsWith("/settings"):
        pageElement = await CreateForm(flowId, "settings");
        break;
      default:
        pageElement = document.createElement("h1");
        pageElement.textContent = `Default Implementation for Page ${route}`;
    }
    if (pageElement) {
      var app = document.getElementById("app");

      app.innerHTML = "";
      app.appendChild(pageElement);
    }

    window.scrollX = 0;
  },
};

export default Router;
frontend/app.js
import Router from "./src/router.js";

window.addEventListener("DOMContentLoaded", async function initRouter() {
  console.log("DOM is ready");
  await Router.init();
});

document.addEventListener(
  "focus",
  function (event) {
    if (
      event.target.tagName === "INPUT" ||
      event.target.tagName === "TEXTAREA" ||
      event.target.tagName === "SELECT"
    ) {
      event.target.scrollIntoView({ behavior: "smooth", block: "center" });
    }
  },
  true
);

Bundling the Frontend

We mentioned that we are using ViteJS6 for bundling our code. We don't do a lot of crazy stuff in this code. Yet one crucial feature we need (not present in VanillaJS) is the ability to override the variables from the environment variables. That is where ViteJS provides a great hand. 🤝

frontend/src/config.js
export const kratosHost = import.meta.env.VITE_KRATOS_HOST || "http://localhost:4433";
export const baseUrl = import.meta.env.VITE_BASE_URL || "http://localhost:8080";

This way, whenever we want to customize the target Kratos server URL, all we have to do is to pass it as an environment variable as below before building the code:

export VITE_KRATOS_HOST="https://kratos.example.com"

Building the frontend

For this project, we have picked Bun7 as our build tool. It's simple & fast ⚡ and does the job well. 💪

frontend/package.json
{
  "dependencies": {
    "vite": "^5.2.11"
  },
  "devDependencies": {
    "@types/bun": "latest",
    "globals": "^15.2.0"
  },
  "module": "app.js",
  "name": "ory-example",
  "scripts": {
    "build": "vite build",
    "dev": "vite",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "type": "module"
}
bun install
bun run build

CI Definition

When our project is ready to be published, we will use GitHub Actions to build and deploy the frontend to the GitHub Pages.

.github/workflows/ci.yml
name: ci
concurrency:
  group: ci-${{ github.event_name }}-${{ github.ref_name }}
  cancel-in-progress: true

on:
  push:
    branches:
      - main

permissions:
  contents: read
  pages: write
  id-token: write

env:
  VITE_KRATOS_HOST: ${{ vars.KRATOS_HOST }}

jobs:
  deploy:
    defaults:
      run:
        working-directory: ./frontend
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Setup bun
        uses: oven-sh/setup-bun@v1
        with:
          bun-version: latest
      - name: Install dependencies
        run: bun install
      - name: Build
        run: bun run build
      - name: Setup Pages
        uses: actions/configure-pages@v5
      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          name: frontend-latest
          path: ./frontend/dist
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v4
        with:
          artifact_name: frontend-latest

With this workflow, upon every push to the main branch we will have our application ready to be served on the GitHub Pages. In our case, this repository is public and there is no charge for the CI, as well as for the GitHub Pages.

GitHub Pages SPA Hack

As of writing this blog post, GitHub Pages does not natively support Single Page Applications17. This is a blocker for our application since it is a SPA. To get around that, we will get help from the community to come up with something a bit creative18.

The idea is to create a custom 404.html which will have enough JavaScript code to redirect the page to our SPA's index.html, having it's URI as query parameter. On the other hand, the index.html will also include a JavaScript code to parse the query parameter and let our Vanilla JS router take care of the rest.

frontend/404.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Developer Friendly</title>
    <script type="text/javascript">
      var pathSegmentsToKeep = 0;

      var l = window.location;
      l.replace(
        l.protocol +
          "//" +
          l.hostname +
          (l.port ? ":" + l.port : "") +
          l.pathname
            .split("/")
            .slice(0, 1 + pathSegmentsToKeep)
            .join("/") +
          "/?/" +
          l.pathname
            .slice(1)
            .split("/")
            .slice(pathSegmentsToKeep)
            .join("/")
            .replace(/&/g, "~and~") +
          (l.search ? "&" + l.search.slice(1).replace(/&/g, "~and~") : "") +
          l.hash
      );
    </script>
  </head>
  <body></body>
</html>

NOTE: The parsing of the query parameter in the index.html has to happen before the our own JS code is loaded. This allows for our router not to worry about this hacky redirection. ❗

frontend/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Ory Example - Developer Friendly</title>
    <link rel="stylesheet" href="/assets/styles.css" />
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer" />
    <script type="text/javascript">
      (function(l) {
        if (l.search[1] === '/' ) {
          var decoded = l.search.slice(1).split('&').map(function(s) {
            return s.replace(/~and~/g, '&')
          }).join('?');
          window.history.replaceState(null, null,
              l.pathname.slice(0, -1) + decoded + l.hash
          );
        }
      }(window.location))
    </script>
    <script src="app.js" defer type="module"></script>
  </head>
  <body>
    <header>
      <nav>
        <ul>
          <li><a href="/">Home</a></li>
          <li><a href="/login">Login</a></li>
          <li><a href="/register">Register</a></li>
          <li><a href="/verify">Verify</a></li>
          <li><a href="/recovery">Recovery</a></li>
          <li><a href="/settings">Settings</a></li>
          <li><a href="/logout">Logout</a></li>
        </ul>
      </nav>
    </header>

    <main id="app"></main>

    <footer>
      <p>&copy; <a href="https://developer-friendly.blog" target="_blank">Developer Friendly</a>. All rights reserved.</p>
      <span><i class="fa-brands fa-github"></i><a href="https://github.com/developer-friendly/ory" target="_blank">Source Code</span></a>
    </footer>
  </body>
</html>

And now we need to include the new 404.html as an asset in ViteJS config:

frontend/vite.config.js
import { defineConfig } from "vite";
import path from "path";

export default defineConfig({
  build: {
    rollupOptions: {
      input: {
        main: path.resolve(__dirname, "index.html"),
        404: path.resolve(__dirname, "404.html"),
      },
    },
  },
});

Logout Flow

Among the flows we mentioned earlier, all can be handled by our "general" flow implementation. However, the logout flow is a bit different19. It requires its own implementation as there will no longer be a form. You may want to include a confirmation page for your app but that's out of scope as far as Kratos server is concerned.

frontend/src/logout.js
import { initFlow } from "./utils.js";

async function createForm() {
  var flowInfo;

  var extraHeaders = {
    accept: "application/json",
  };

  flowInfo = await initFlow("logout", extraHeaders);

  var flowJson = await flowInfo.json();

  await fetch(flowJson.logout_url, { credentials: "include" });

  window.location.href = "/login";
}

export default createForm;

Bonus: GitHub Pages Custom Domain

The application we have deployed in the GitHub Pages so far is accessible with the URL assigned by GitHub to each repository's Pages instance.

https://USERNAME.github.io/REPOSITORY

In our case, that turns out to be the following format:

https://developer-friendly.github.io/ory

There is nothing wrong with this URL. However, in a serious production application, you would want to have your own domain name. This is where the custom domain name comes in; And unlike other service providers, GitHub does not charge you extra for this feature.

The DNS record we want to create should be the following:

Record Type Record Name Target Value
CNAME ory.developer-friendly.blog developer-friendly.github.io

And since the developer-friendly.blog domain is hosted on Cloudflare, here's how the IaC will look like for such a change.

dns/variables.tf
variable "cloudflare_api_token" {
  type      = string
  nullable  = false
  sensitive = true
}
dns/versions.tf
terraform {
  required_providers {
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 4.33"
    }
  }
}

provider "cloudflare" {
  api_token = var.cloudflare_api_token
}
dns/main.tf
data "cloudflare_zone" "devfriend_blog" {
  name = "developer-friendly.blog"
}

resource "cloudflare_record" "ory" {
  zone_id = data.cloudflare_zone.devfriend_blog.id

  name    = "ory"
  proxied = true
  ttl     = 1
  type    = "CNAME"
  value   = "developer-friendly.github.io"
}

Now, let's apply this stack using OpenTofu:

export TF_VAR_cloudflare_api_token="PLACEHOLDER"
tofu init
tofu plan -out tfplan
tofu apply tfplan

Conclusion

That wraps up all we had to say for this blog post. The main objective was to create a custom frontend for the Ory Kratos server.

As you saw in the post, the root domain for both the Kratos server and the frontend should be the same for the cookies to work.

Among many benefits that Kratos brings to the table, many years of development and feedback from the community, following security best practices based on the well-known recommendations and standards20, not reinventing the wheel, and separation of concern are just a few to name.

I honestly rarely think of writing my own authentication and identity management system these days anymore. Cause Kratos does a perfect job at what it was meant to. I invite you to also tip your toes and give it a fair shot if you haven't already.

I know many folks might prefer other alternatives like Keycloak, Auth0, or Firebase. And that's perfectly fine. The choice is yours to make. However, don't let that stop you from exploring what Ory Kratos has to offer.

In a future post, I will explore more of the Ory products and the intersection of them all when it comes to delivering a robust auth solution on top of your application logic.

I wish you have gained something from this post, and I hope you forgive me for the possible awful frontend code an SRE guy has provided before your eyes. 😅

I have learned a lot from Kyle Simpson's21 You Don't Know JS series and most of the code you've seen here are following the patterns he teaches. That is to say that I have no regret not using arrow functions, avoiding the overloaded use of const keyword, and not using triple equals === everywhere. 😉

Until next time 🫡, ciao 🤠 and happy hacking! 🐧 🦀

If you enjoyed this blog post, consider sharing it with these buttons 👇. Please leave a comment for us at the end, we read & love 'em all. ❣

Share on Share on Share on Share on

Comments