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!

Introduction

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.

Subscribe to the Newsletter

Receive the latest blog post updates in your mailbox.

    No Spam. Unsubscribe at any time.

    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.

    Objective

    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: [email protected]
        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": [
                "[email protected]"
              ],
              "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=[email protected]
    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