Cogs and Levers A blog full of technical stuff

Building a Stack-Based VM in Rust - Part 3

Introduction

In Part 2, we extended our Forth-style virtual machine with a bunch of classic stack manipulation words — from OVER and ROT to 2DUP, 2SWAP, and more.

This gave our machine more expressive power, but it still lacked something crucial: control flow. In this part, we’ll fix that.

By adding branching and subroutine support, we allow our VM to make decisions and reuse logic — two foundational ideas in all real programming languages.

Control Flow in Stack Machines

Stack machines like Forth typically handle control flow through explicit instruction manipulation — that is, jumping to new parts of the program and returning when done.

We’ll implement:

Instruction Stack Effect Description
IfZero(offset) ( n -- ) Jumps offset if top is zero
Jump(offset) ( -- ) Always jumps offset
Call(addr) ( -- ) Saves return address and jumps
Return ( -- ) Pops return address and jumps to it

These instructions give us the power to create conditionals and function-like routines.

Extending the Instruction Set

Let’s extend our enum with the new operations:

enum Instruction {
    Push(i32),
    Add,
    Mul,
    Dup,
    Drop,
    Swap,
    Over,
    Rot,
    Nip,
    Tuck,
    TwoDup,
    TwoDrop,
    TwoSwap,
    Depth,
    Jump(isize),     // new
    IfZero(isize),   // new
    Call(usize),     // new
    Return,          // new
    Halt,
}

In order to support our ability to call subroutines, our virtual machine needs another stack. This stack is in charge of remembering where we came from so that we can return back to the correct place. The return stack is just another piece of state management for the virtual machine:

struct VM {
    stack: Vec<i32>,
    program: Vec<Instruction>,
    ip: usize,
    return_stack: Vec<usize>,       // new
}

And make sure VM::new() initializes that new return stack:

impl VM {
    fn new(program: Vec<Instruction>) -> Self {
        Self {
            stack: Vec::new(),
            program,
            ip: 0,
            return_stack: Vec::new(),       // new
        }
    }
}

Implementing Control Instructions

Each control instruction is added to the run() method just like any other:

JUMP

Unconditionally jumps to a new offset from the current instruction pointer.

Stack effect: ( -- )

Instruction::Jump(offset) => {
    self.ip = ((self.ip as isize) + offset) as usize;
    continue;
}

We use continue here because we don’t want to execute the usual ip += 1 after a jump.

IFZERO

Conditionally jumps based on the top stack value.

Stack effect: ( n -- )

Instruction::IfZero(offset) => {
    let cond = self.stack.pop().expect("Stack underflow on IFZERO");
    if cond == 0 {
        self.ip = ((self.ip as isize) + offset) as usize;
        continue;
    }
}

If the value is zero, we adjust ip by the offset. If not, we let the loop continue as normal.

CALL

Pushes the current instruction pointer onto the return stack and jumps to the absolute address.

Stack effect: ( -- )

Instruction::Call(addr) => {
    self.return_stack.push(self.ip + 1);
    self.ip = *addr;
    continue;
}

We store ip + 1 so that Return knows where to go back to.

RETURN

Pops the return stack and jumps to that address.

Stack effect: ( -- )

Instruction::Return => {
    let ret = self.return_stack.pop().expect("Return stack underflow");
    self.ip = ret;
    continue;
}

This makes it possible to write reusable routines, just like functions.

Example: Square a Number

Let’s write a subroutine that squares the top value of the stack — like this:

: square dup * ;
5 square

Translated into VM instructions:

let program = vec![
    // main
    Instruction::Push(5),       // [5]
    Instruction::Call(3),       // jump to square
    Instruction::Halt,

    // square (addr 3)
    Instruction::Dup,           // [5, 5]
    Instruction::Mul,           // [25]
    Instruction::Return,
];

let mut vm = VM::new(program);
vm.run();
println!("Final stack: {:?}", vm.stack);

Expected output:

[25]

If you accidentally used Call(5), you’d be jumping to Return, skipping your routine completely — a classic off-by-one bug that’s easy to spot once you think in terms of instruction addresses.

Conclusion

With these new control flow instructions, we’ve unlocked a huge amount of expressive power. Our VM can now:

  • Execute conditional logic
  • Jump forwards and backwards
  • Encapsulate and reuse stack behavior with subroutines

In the next part, we’ll take the leap into defining named words, allowing us to simulate real Forth syntax like:

: square dup * ;
5 square

We’ll build a dictionary, wire up some simple parsing, and move closer to an interactive REPL.

The code for this part is available here on GitHub.

Building a Stack-Based VM in Rust - Part 2

Introduction

In Part 1, we built the foundation of a Forth-inspired stack-based virtual machine in Rust. It could execute arithmetic expressions using a simple data stack, with support for operations like PUSH, ADD, MUL, and basic stack manipulation like DUP, DROP, and SWAP.

In this post, we’re going to extend our instruction set with a broader set of stack manipulation words, modeled after standard Forth operations.

Why focus on stack operations? Because in a language like Forth, the stack is everything. Understanding and manipulating it precisely is key to building complex programs — without variables, parentheses, or traditional control structures.

Stack Operations in Forth

Let’s take a look at some of the classic stack words used in Forth and what they do:

Word Stack Effect Description
OVER ( a b -- a b a ) Copies the second value to the top
ROT ( a b c -- b c a ) Rotates the third value to the top
NIP ( a b -- b ) Removes the second item
TUCK ( a b -- b a b ) Duplicates the top item under the second
2DUP ( a b -- a b a b ) Duplicates the top two items
2DROP ( a b -- ) Drops the top two items
2SWAP ( a b c d -- c d a b ) Swaps the top two pairs
DEPTH ( -- n ) Pushes the current stack depth

These tiny instructions are the building blocks for everything from loops and conditionals to data structures and control flow. Let’s implement them.

Extending the Instruction Set

First, we add new variants to our Instruction enum:

enum Instruction {
    Push(i32),
    Add,
    Mul,
    Dup,
    Drop,
    Swap,
    Over,
    Rot,
    Nip,
    Tuck,
    TwoDup,
    TwoDrop,
    TwoSwap,
    Depth,
    Halt,
}

Implementing the New Instructions

Each of these stack operations is implemented as a new match arm in our run() method. Here’s the complete method with all new instructions included:

OVER

Copies the second value from the top and pushes it to the top.

Stack effect: ( a b -- a b a )

Instruction::Over => {
    if self.stack.len() < 2 {
        panic!("Stack underflow on OVER");
    }
    let val = self.stack[self.stack.len() - 2];
    self.stack.push(val);
}

This implementation uses indexing to read the second-to-top value without popping. It’s a clean operation that doesn’t disturb the existing stack order — a very common primitive in Forth.

ROT

Rotates the third item to the top of the stack.

Stack effect: ( a b c -- b c a )

Instruction::Rot => {
    if self.stack.len() < 3 {
        panic!("Stack underflow on ROT");
    }
    let c = self.stack.pop().unwrap();
    let b = self.stack.pop().unwrap();
    let a = self.stack.pop().unwrap();
    self.stack.push(b);
    self.stack.push(c);
    self.stack.push(a);
}

We pop all three values, then push them back in rotated order. It’s a destructive operation — it reshuffles the top 3 items completely.

NIP

Removes the second item, leaving the top item alone.

Stack effect: ( a b -- b )

Instruction::Nip => {
    if self.stack.len() < 2 {
        panic!("Stack underflow on NIP");
    }
    let top = self.stack.pop().unwrap();
    self.stack.pop(); // discard second
    self.stack.push(top);
}

Here we temporarily save the top, discard the second, then restore the top. This is essentially “keep the top, ignore the rest.”

TUCK

Duplicates the top item and inserts it beneath the second.

Stack effect: ( a b -- b a b )

Instruction::Tuck => {
    if self.stack.len() < 2 {
        panic!("Stack underflow on TUCK");
    }
    let top = *self.stack.last().unwrap();
    self.stack.insert(self.stack.len() - 2, top);
}

We avoid popping by using last() and insert(). Inserting at len() - 2 puts the copy just beneath the second item, preserving the original order.

2DUP

Duplicates the top two stack items.

Stack effect: ( a b -- a b a b )

Instruction::TwoDup => {
    if self.stack.len() < 2 {
        panic!("Stack underflow on 2DUP");
    }
    let len = self.stack.len();
    self.stack.push(self.stack[len - 2]);
    self.stack.push(self.stack[len - 1]);
}

We peek at the last two items and push duplicates in-place. It’s a straightforward double copy.

2DROP

Removes the top two items from the stack.

Stack effect: ( a b -- )

Instruction::TwoDrop => {
    if self.stack.len() < 2 {
        panic!("Stack underflow on 2DROP");
    }
    self.stack.pop();
    self.stack.pop();
}

Just two pops in a row. Very simple and direct.

2SWAP

Swaps the top two pairs on the stack.

Stack effect: ( a b c d -- c d a b )

Instruction::TwoSwap => {
    if self.stack.len() < 4 {
        panic!("Stack underflow on 2SWAP");
    }
    let d = self.stack.pop().unwrap();
    let c = self.stack.pop().unwrap();
    let b = self.stack.pop().unwrap();
    let a = self.stack.pop().unwrap();
    self.stack.push(c);
    self.stack.push(d);
    self.stack.push(a);
    self.stack.push(b);
}

This is the most complex so far. We destructure two pairs from the stack, then push them back in swapped order.

DEPTH

Pushes the number of elements currently on the stack.

Stack effect: ( -- n )

Instruction::Depth => {
    let depth = self.stack.len() as i32;
    self.stack.push(depth);
}

No stack input required. Just measure and push. Very handy for introspection or debugging.

Example: Forth-ish Stack Dance

Let’s build a small program using some of these new instructions:

let program = vec![
    Instruction::Push(1),
    Instruction::Push(2),
    Instruction::Push(3),
    Instruction::Rot,      // [2, 3, 1]
    Instruction::Over,     // [2, 3, 1, 3]
    Instruction::Add,      // [2, 3, 4]
    Instruction::TwoDup,   // [2, 3, 4, 3, 4]
    Instruction::Swap,     // [2, 3, 4, 4, 3]
    Instruction::TwoDrop,  // [2, 3, 4]
    Instruction::Depth,    // [2, 3, 4, 3]
    Instruction::Halt,
];

let mut vm = VM::new(program);
vm.run();
println!("Final stack: {:?}", vm.stack);

The final stack should look like this:

[2, 3, 4, 3]

That last 3 is the result of DEPTH, reporting how many values were on the stack before it was called.

Conclusion

With just a few additional instructions, our little VM has become much more expressive. We’ve added powerful new tools to inspect, duplicate, and reorder values on the stack — just like a real Forth environment.

This kind of “stack choreography” might feel alien at first, but it’s deeply intuitive once you start thinking in terms of data flow. It’s the perfect foundation for:

  • Building control structures
  • Defining new words
  • Supporting conditionals and loops
  • Creating a REPL

And that’s where we’re headed next.

The code for this part is available up in my github.

Building a Stack-Based VM in Rust - Part 1

Introduction

Most of the code we write is eventually executed by some kind of virtual machine — whether it’s the JVM, the CLR, or the many interpreters embedded in your browser or shell.

But how do these machines actually work?

To understand this from the ground up, we’re going to build a stack-based virtual machine — the simplest kind of VM there is.

Stack Machines and Reverse Polish Notation

Unlike register-based architectures (like x86 or ARM), stack-based machines use a single stack for passing arguments and storing temporary values. Instructions operate by pushing and popping values to and from this stack.

For example, instead of writing:

x = (2 + 3) * 4

You would write it in Reverse Polish Notation (RPN):

2 3 + 4 *

This evaluates like so:

  1. Push 2
  2. Push 3
  3. Add them (5)
  4. Push 4
  5. Multiply (5 * 4 = 20)

This is not just a novelty — it’s how many early languages and calculators (like HP RPN calculators) worked. It eliminates the need for parentheses and operator precedence, making parsing trivial.

Enter Forth

Forth is a language built entirely on this stack-based model. It’s terse, powerful, and famously minimalist. Every Forth program is a sequence of words (commands) that manipulate the data stack. New words can be defined at runtime, giving Forth a unique mix of interactivity and extensibility.

Despite being decades old, the design of Forth still holds up as a brilliant way to think about interpreters, minimal systems, and direct computing.

Here’s an example of a simple Forth snippet:

: square ( n -- n^2 ) dup * ;
5 square

This defines a word square that duplicates the top of the stack and multiplies it by itself. Then it pushes 5 and runs square, leaving 25 on the stack.

Why Rust?

Rust gives us a perfect platform for building this kind of system:

  • It’s low-level enough to model memory and data structures precisely.
  • It’s safe and expressive, letting us move fast without segmentation faults.
  • It encourages clean architecture and high-performance design.

Over the next few posts, we’ll build a small but functional Forth-inspired virtual machine in Rust. In this first part, we’ll get a simple instruction set up and running — enough to perform arithmetic with a data stack.

Let’s get started.

Defining a Machine

Let’s start by defining the fundamental pieces of our stack-based virtual machine.

Our machine is going to be made up of some basic building blocks such as:

  • An instruction set (things to execute)
  • A stack (to hold our state)
  • A machine structure (something to bundle our pieces together)

The Instruction Set

First, we need a basic set of instructions. These represent the operations our VM knows how to perform. We’ll keep it simple to begin with:

  • Push(n) – Push a number onto the stack
  • Add – Pop two values, push their sum
  • Mul – Pop two values, push their product
  • Dup – Duplicate the top value on the stack
  • Drop – Discard the top value
  • Swap – Swap the top two values
  • Halt – Stop execution

We express these as a Rust enum:

#[derive(Debug)]
enum Instruction {
    Push(i32),
    Add,
    Mul,
    Dup,
    Drop,
    Swap,
    Halt,
}

That’s the start of what our machine will be capable of executing. As we move through this series, this enum will gather more and more complex operations that we can execute. For now though, these basic arithmetic operations will be a good start.

The Machine

Now let’s define the structure of the virtual machine itself. Our VM will contain:

  • A stack (Vec<i32>) for evaluating instructions
  • A program (Vec<Instruction>) which is just a list of instructions to run
  • An instruction pointer (ip) to keep track of where we are in the program
#[derive(Debug)]
struct VM {
    stack: Vec<i32>,
    program: Vec<Instruction>,
    ip: usize, // instruction pointer
}

impl VM {
    fn new(program: Vec<Instruction>) -> Self {
        Self {
            stack: Vec::new(),
            program,
            ip: 0,
        }
    }

    // We'll implement `run()` in the next section...
}

This lays the foundation for our virtual machine. In the next section, we’ll bring it to life by writing the dispatch loop that runs our program.

run(): Getting Things Done

Now that we have a structure for our VM, it’s time to give it life — with a run() function.

This will be our dispatch loop — the engine that drives our machine. It will:

  • Read the instruction at the current position (ip)
  • Execute it by manipulating the stack
  • Move to the next instruction
  • Halt when we encounter the Halt instruction

Let’s add this to our impl VM block:

fn run(&mut self) {
    while self.ip < self.program.len() {
        match &self.program[self.ip] {
            Instruction::Push(value) => {
                self.stack.push(*value);
            }
            Instruction::Add => {
                let b = self.stack.pop().expect("Stack underflow on ADD");
                let a = self.stack.pop().expect("Stack underflow on ADD");
                self.stack.push(a + b);
            }
            Instruction::Mul => {
                let b = self.stack.pop().expect("Stack underflow on MUL");
                let a = self.stack.pop().expect("Stack underflow on MUL");
                self.stack.push(a * b);
            }
            Instruction::Dup => {
                let top = *self.stack.last().expect("Stack underflow on DUP");
                self.stack.push(top);
            }
            Instruction::Drop => {
                self.stack.pop().expect("Stack underflow on DROP");
            }
            Instruction::Swap => {
                let b = self.stack.pop().expect("Stack underflow on SWAP");
                let a = self.stack.pop().expect("Stack underflow on SWAP");
                self.stack.push(b);
                self.stack.push(a);
            }
            Instruction::Halt => break,
        }

        self.ip += 1;
    }
}

This loop is dead simple — and that’s exactly what makes it elegant. There are no registers, no heap, no branches just yet — just a list of instructions and a stack to evaluate them on.

The use of expect on each of our pop operations is a small insurance policy. This allows us to report out and invalid state on the stack. If we’re already at the top of stack (TOS) then we can’t pop more values.

In future parts, we’ll introduce new instructions to handle control flow, user-defined words, and maybe even a return stack — all inspired by Forth.

But before we get ahead of ourselves, let’s write a small program and run it.

Running

We don’t have a parser or compiler yet, so we need to write our Forth program directly inside the Rust code. This will take the form of a vector of instructions:

let program = vec![
    Instruction::Push(2),
    Instruction::Push(3),
    Instruction::Add,
    Instruction::Push(4),
    Instruction::Mul,
    Instruction::Halt,
];

If you squint a little, you’ll notice this is equivalent to the following Forth-style program:

2 3 + 4 *

This is exactly the kind of thing you’d see in a Reverse Polish or Forth-based environment — values and operations in sequence, evaluated by a stack machine.

Now, let’s run our program and inspect the result:

let mut vm = VM::new(program);
vm.run();

println!("Final stack: {:?}", vm.stack);

If everything has gone to plan, you should see this output in your terminal:

[20]

Giving us the final answer of 20. That confirms our machine is working — it’s reading instructions, performing arithmetic, and leaving the result on the stack. A tiny virtual computer, built from scratch.

Conclusion

We’ve built the foundation of a working virtual machine — one that can evaluate simple arithmetic using a stack, just like a classic Forth system. It’s small, simple, and powerful enough to demonstrate key ideas behind interpreters, instruction dispatch, and virtual machines.

In future posts, we’ll extend this VM to include:

  • More complex stack operations
  • Named words and a dictionary
  • Control flow (if, loop, etc.)
  • A basic text-based REPL
  • And maybe even user-defined memory or variables

The code for this article can be found here.

Untangling OAuth, OAuth2, and OpenID Connect

Introduction

Authentication and authorization power almost everything we do online — but these words are thrown around so much, they’re often misunderstood. Add in terms like OAuth2, OpenID Connect, tokens, flows, and even FAPI, and suddenly you’re in acronym soup.

This post is here to untangle the mess.

We’ll walk through the big ideas behind OAuth and OpenID Connect, introduce the core roles and flows, and build a set of intuitive examples you can base your mental model on. By the end, you’ll know:

  • The difference between authentication and authorization
  • What OAuth2 actually does (and what it doesn’t)
  • The roles: Resource Owner, Client, Authorization Server, Resource Server
  • The different flows — and when to use each
  • How OpenID Connect builds login flows on top of OAuth2

We won’t cover OAuth in this article. OAuth as a concept has been around since 2007. The original version — OAuth 1.0a — solved the problem of granting third-party access to user data without passwords, but it required complex cryptographic signing and didn’t assume HTTPS. OAuth2 replaced it with a cleaner, TLS-based approach that’s now the foundation for everything from “Login with Google” to Open Banking APIs.

Authorization vs Authentication

Let’s get the definitions straight first:

  • Authentication = Who are you?
  • Authorization = What are you allowed to do?

Think of a hotel:

  • Showing your ID at the front desk = authentication
  • Being given a keycard for your room = authorization

OAuth2 was designed for authorization, not login. But because it passes identity-ish tokens around, people started using it for login flows — which is what OpenID Connect was built to formalize.

OAuth2 Roles

OAuth2 involves four key actors:

Role Description
Resource Owner The user who owns the data or resource
Client The app that wants to use the resource
Authorization Server The service that authenticates the user and issues tokens
Resource Server The API or service holding the protected resource

Example:

  • You’re the Resource Owner - you own your GitHub profile
  • GitHub is the Authorization Server
  • A third-party app (like VSCode) is the Client
  • GitHub’s API is the Resource Server

These roles are who will be playing different parts when we go to explain the OAuth2 flows in the next section.

OAuth2 Flows

OAuth2 defines several flows, depending on the type of client and security model.

Authorization Code Flow

Used when

  • Your client is a web app executing on the server-side
  • Your client is a mobile apps and this can be used with PKCE

Steps:

  1. Client sends user to Authorization Server’s authorize endpoint (typically a browser redirect)
  2. User logs in, approves scopes
  3. Server redirects back to client with a code
  4. Client sends the code (plus credentials) to token endpoint
  5. Client receives access token, optionally refresh token
sequenceDiagram participant User participant Client participant AuthServer as Authorization Server User->>Client: (1a) Initiates login Client->>AuthServer: (1b) Redirect user to authorize endpoint User->>AuthServer: (2) Login + Consent AuthServer-->>Client: (3) Redirect with Authorization Code Client->>AuthServer: (4) Exchange Code (+ Verifier) AuthServer-->>Client: (5) Access Token (+ Refresh Token)

Why it’s good:

  • Keeps tokens off the front-end, as the access token is passed directly to the server hosting the client
  • Supports refresh tokens

  • Use with PKCE for mobile/SPAs

Client Credentials Flow

Used when:

  • The client is the resource owner
  • Machine-to-machine access (no user)
  • Server-side automation, microservices, etc.

Steps:

  1. Client authenticates to the token endpoint directly
  2. Sends its client ID and secret
  3. Gets an access token
  4. Client now accesses protected resource
sequenceDiagram participant Client participant AuthServer as Authorization Server participant Resource as Resource Server Client->>AuthServer: (1) Authenticate with client_id + secret AuthServer-->>Client: (2) Access Token Client->>Resource: (3) API call with token Resource-->>Client: (4) Protected resource

Use this in situations where there is no user involved.

Resource Owner Password Credentials (ROPC) Flow

Used when:

  • The client is completely trusted with user credential
  • Really only for legacy apps

Should you use it? No. Never. It’s deprecated.

Steps:

  1. User gives username and password directly to client
  2. Client sends them to token endpoint
  3. Gets access token
sequenceDiagram participant User participant Client participant AuthServer as Authorization Server User->>Client: (1) Provide username + password Client->>AuthServer: (2) Forward credentials AuthServer-->>Client: (3) Access Token

Why it’s bad:

  • Client sees the user’s password.
Warning: Don't do this anymore.

Device Authorization Flow

Used when:

  • The client is a Smart TV or console
  • The client is CLI tools

Steps:

  1. Client requests a device + user code from token endpoint
  2. Device shows the user code and asks user to visit a URL
  3. User logs in on their phone/laptop
  4. Client polls the token endpoint until authorized
  5. Gets access token
sequenceDiagram participant Client participant User participant AuthServer as Authorization Server Client->>AuthServer: (1) Request device_code + user_code AuthServer-->>Client: (2) Return codes Client->>User: (2b) Display code + URL User->>AuthServer: (3) Log in + consent on separate device Client->>AuthServer: (4) Poll token endpoint AuthServer-->>Client: (5) Access Token

No browser on the device needed!

Common on Xbox, Apple TV, etc.

PKCE – Proof Key for Code Exchange

Originally designed for mobile apps, PKCE (pronounced “pixy”) adds extra safety to the Authorization Code Flow.

Why it matters:

  • Public clients can’t hold secrets
  • PKCE protects the code exchange from being hijacked

How it works:

  1. Client generates a random code_verifier
  2. Derives a code_challenge = SHA256(code_verifier)
  3. Sends the code_challenge with the initial authorize request
  4. Exchanges the code using the original code_verifier
sequenceDiagram participant Client Client->>Client: (1) Generate code_verifier Client->>Client: (2) Derive code_challenge = SHA256(code_verifier) Client->>AuthServer: (3) Send code_challenge with auth request Client->>AuthServer: (4) Exchange code + code_verifier at token endpoint

Required in: All public clients, including SPAs and mobile apps

Hybrid Flow (OIDC-specific)

Used when:

  • Apps that want both id_token and code at once

Combines:

  • Immediate authentication (id_token)
  • Deferred authorization (code → access_token)

An example of this is when a login page that needs to show the user’s name immediately, but still needs a backend exchange for secure API calls

OpenID Connect

OAuth2 doesn’t handle identity. That’s where OpenID Connect (OIDC) steps in. It’s a layer on top of OAuth2 that turns it into a proper login protocol.

OIDC adds:

  • id_token: A JWT that proves who the user is
  • userinfo endpoint: For extra user profile data
  • openid scope: Triggers identity behavior
  • /.well-known/openid-configuration: A discovery doc

How it works (OpenID Connect Flow):

  1. Client redirects to authorization server with response_type=code&scope=openid
  2. User logs in and approves
  3. Server returns code
  4. Client exchanges code for:
    • access_token
    • id_token
  5. Client validates id_token (aud, iss, exp, sig)
sequenceDiagram participant User participant Client participant AuthServer as Authorization Server Client->>AuthServer: (1) Redirect with response_type=code&scope=openid User->>AuthServer: (2) Log in + consent AuthServer-->>Client: (3) Authorization Code Client->>AuthServer: (4) Exchange code AuthServer-->>Client: (4b) id_token + access_token Client->>Client: (5) Validate id_token (aud, iss, exp, sig)

You now know who the user is and can access their resources.

Financial-grade API (FAPI)

OAuth2 and OpenID Connect cover most identity and authorization needs — but what if you’re building a system where the stakes are higher?

That’s where FAPI comes in: a set of specifications designed for open banking, financial APIs, and identity assurance. It builds on OAuth2 and OIDC with tighter security requirements.

FAPI is all about turning “pretty secure” into “regulatory-grade secure.”

Why FAPI Exists

If you’re authorizing access to:

  • A bank account
  • A user’s verified government identity
  • A payment transaction

… then normal OAuth2 flows may not be enough. You need stronger client authentication, proof that messages haven’t been tampered with, and assurances that the user really is who they say they are.

What FAPI Adds

Feature Purpose
PKCE (mandatory) Protects public clients from auth code injection
JARM (JWT Authorization Response Mode) Wraps redirect responses in signed JWTs
MTLS / private_key_jwt Strong client authentication — no shared client secret
PAR (Pushed Authorization Requests) Sends authorization parameters directly to the server, not via browser
Signed request objects Prevent tampering of requested scopes or redirect URIs
Claims like acr, amr Express the authentication context (e.g. MFA level)

FAPI isn’t a new protocol — it’s a profile that narrows and strengthens how you use OAuth2 and OpenID Connect.

FAPI Profiles

FAPI 1.0 comes in two flavors:

  • Baseline – For read-only access (e.g. viewing account balances)
  • Advanced – For write access (e.g. initiating payments), identity proofing, or legal-grade authorization
    Requires things like:
    • Signed request parameters (request JWTs)
    • Mutual TLS or private_key_jwt authentication
    • JARM (JWT-wrapped authorization responses)

FAPI Authorization Flow (Simplified)

This diagram shows a high-assurance Authorization Code Flow with FAPI extensions: PAR, private_key_jwt, and JARM.

sequenceDiagram participant Client participant AuthServer as Authorization Server participant User participant Resource as Resource Server Client->>AuthServer: (1) POST pushed authorization request (PAR) [signed] AuthServer-->>Client: (2) PAR URI Client->>User: (3) Redirect user with PAR URI User->>AuthServer: (4) Login + Consent AuthServer-->>Client: (5) Redirect with JARM JWT Client->>AuthServer: (6) Exchange code (with private_key_jwt) AuthServer-->>Client: (7) Access Token (+ id_token) Client->>Resource: (8) Access resource with token

This flow is intentionally strict:

  • The authorization request is sent directly to the server via PAR, not through query parameters
  • The response (auth code) is wrapped in a signed JWT (JARM) to ensure integrity
  • The client proves its identity with a private key, not a shared secret
  • All tokens and id_tokens are validated just like in OpenID Connect

Should You Use FAPI?

Use Case FAPI Needed?
“Login with Google” or GitHub? ❌ No
A typical SaaS dashboard? ❌ No
Open Banking APIs (UK, EU, AU)? ✅ Yes
Authorizing government-verified identities? ✅ Yes
Performing financial transactions or issuing payments? ✅ Absolutely

It’s not meant for everyday OAuth — it’s for high-security environments that require strong trust guarantees and auditability.

Conclusion

OAuth2 and OpenID Connect underpin almost every secure app on the internet — but they aren’t simple. They describe a flexible framework, not a single implementation, and that’s why they feel confusing.

Pitfalls and Best Practices

Do

  • Always use PKCE (mandatory for public clients)
  • Use short-lived access tokens and refresh tokens
  • Validate all tokens — especially id_token
  • Never store tokens in localStorage
  • Use FAPI when dealing with banking

Don’t

  • Don’t use implicit flow anymore
  • Don’t mix up access_token and id_token

If you want more information, here are some helpful links.

Building a Minimal JIT Compiler in Rust with Cranelift

Introduction

Most of the time, we think of programs as static — we write code, compile it, and run it. But what if our programs could generate and execute new code at runtime?

This technique, called dynamic code generation, underpins technologies like:

  • High-performance JavaScript engines (V8, SpiderMonkey)
  • Regex engines (like RE2’s code generation)
  • AI compilers like TVM or MLIR-based systems
  • Game scripting engines
  • Emulators and binary translators

In this post, we’ll explore the idea of just-in-time compilation (JIT) using Rust and a powerful but approachable backend called Cranelift.

Rather than building a full language or VM, we’ll create a simple JIT compiler that can dynamically compile a function like:

fn add(a: i32, b: i32) -> i32 {
  a + b
}

And run it — at runtime.

Let’s break this down step by step.

What is Cranelift?

Cranelift is a low-level code generation framework built by the Bytecode Alliance. It’s designed for:

  • Speed: It compiles fast, making it ideal for JIT scenarios.
  • Portability: It works across platforms and architectures.
  • Safety: It’s written in Rust, and integrates well with Rust codebases.

Unlike LLVM, which is a powerful but heavyweight compiler infrastructure, Cranelift is laser-focused on emitting machine code with minimal overhead.

Dependencies

First up, we have some dependencies that we need to install into the project.

[dependencies]
cranelift-jit = "0.119"
cranelift-module = "0.119"
cranelift-codegen = "0.119"
cranelift-frontend = "0.119"

The Code

Context Setup

We begin by creating a JIT context using Cranelift’s JITBuilder and JITModule:

use cranelift_jit::{JITBuilder, JITModule};
use cranelift_module::{Linkage, Module};
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
let mut builder = JITBuilder::new(cranelift_module::default_libcall_names())?;
let mut module = JITModule::new(builder);

    // ...
    Ok(())
}

This sets up a dynamic environment where we can define and compile functions on the fly.

The Function Signature

Next, we define the function signature for our add(i32, i32) -> i32 function:

use cranelift_codegen::ir::{types, AbiParam};

let mut sig = module.make_signature();
sig.params.push(AbiParam::new(types::I32));
sig.params.push(AbiParam::new(types::I32));
sig.returns.push(AbiParam::new(types::I32));

This tells Cranelift the number and type of arguments and the return value.

Declaring the Function

We now declare this function in the module:

let func_id = module.declare_function("add", Linkage::Export, &sig)?;

This returns a FuncId we’ll use to reference and later finalize the function.

Now we build out the fuction body.

This is where we emit Cranelift IR using FunctionBuilder.

use cranelift_frontend::{FunctionBuilder, FunctionBuilderContext};
use cranelift_codegen::ir::InstBuilder;

let mut ctx = module.make_context();
ctx.func.signature = sig;

let mut builder_ctx = FunctionBuilderContext::new();
let mut builder = FunctionBuilder::new(&mut ctx.func, &mut builder_ctx);

let block = builder.create_block();
builder.append_block_params_for_function_params(block);
builder.switch_to_block(block);
builder.seal_block(block);

// Extract arguments
let a = builder.block_params(block)[0];
let b = builder.block_params(block)[1];

// Perform addition and return
let sum = builder.ins().iadd(a, b);
builder.ins().return_(&[sum]);

builder.finalize();

This constructs a Cranelift function that takes two i32s, adds them, and returns the result.

Compiling and Executing

Once the IR is built, we compile and retrieve a function pointer:

module.define_function(func_id, &mut ctx)?;
module.clear_context(&mut ctx);
module.finalize_definitions();

let code_ptr = module.get_finalized_function(func_id);
let func = unsafe { std::mem::transmute::<_, fn(i32, i32) -> i32>(code_ptr) };

let result = func(7, 35);
println!("7 + 35 = {}", result);

Because we’re turning a raw pointer into a typed function, this step is unsafe. We promise the runtime that we’ve constructed a valid function that respects the signature we declared.

Final Result

When run, the output is:

7 + 35 = 42

We dynamically constructed a function, compiled it, and executed it — at runtime, without ever writing that function directly in Rust!

Where to Go From Here

This is just the beginning. Cranelift opens the door to:

  • Building interpreters with optional JIT acceleration
  • Creating domain-specific languages (DSLs)
  • Writing high-performance dynamic pipelines (e.g. for graphics, audio, AI)
  • Implementing interactive REPLs with on-the-fly function definitions

You could expand this project by:

  • Parsing arithmetic expressions and generating IR
  • Adding conditionals or loops
  • Exposing external functions (e.g. math or I/O)
  • Dumping Cranelift IR for inspection
println!("{}", ctx.func.display());

Conclusion

Dynamic code generation feels like magic — and Cranelift makes it approachable, fast, and safe.

In a world where flexibility, speed, and composability matter, being able to build and run code at runtime is a superpower. Whether you’re building a toy language, optimizing a runtime path, or experimenting with compiler design, Cranelift is a fantastic tool to keep in your Rust toolbox.

If this post helped you peek behind the curtain of JIT compilers, I’d love to hear from you. Let me know if you’d like to see this example expanded into a real toy language!