---
title: 'Token-Based Authentication'
source: 'https://academia.sh/en/courses/enterprise-application-framework/token-based-authentication'
course: 'The Enterprise Application Framework'
language: en
updated: '2026-09-13T05:08:56+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures how many claims a token carries and shows that no session record is kept on the server side at all'
    - 'Compares, by running it, whether the same authorization question is answered by an annotation or by a comparison in the source'
    - 'Measures how many steps a revoked token keeps being counted as valid until it expires, because the validator never looks at the revocation list at all'
---

# Token-Based Authentication

In a stateless session, everything about identity stops at the claims a token carries; there is no session record on the server side at all. Whether an authorization question is answered by an annotation or by a comparison in the source is separated by running it; the limiting measurement shows, by count, how many steps a revoked token keeps being treated as valid until it expires, because the validator never looks at any revocation list at all.

The previous lesson took the caller's role as a ready-made string and never asked where that
string came from. This lesson asks exactly that question. A server can know who a caller is in
one of two ways: either it keeps a record for that caller on its own side for every request, or
it stores everything about identity in a token the caller carries, and keeps nothing on its own
side at all. The second is a **stateless session**: the server's memory holds zero records
about the caller, and the identity of every request arrives inside that request itself. This
lesson models the token — without writing a real key, a real signature algorithm, or a real
cryptography routine — and measures two things: the number of claims the token carries, and
whether the answer to an authorization question comes from an annotation or from a call in the
source. The limiting measurement is saved for last, because statelessness has a cost, and this
cost is visible only by running the token through its lifetime.

In a session-based approach the server keeps a record for every caller; deleting the record
invalidates the caller instantly, because every request already looks at that record. The
stateless approach never performs that lookup at all — proof of identity arrives with every
request, and there is no record to query on the server side. This difference is not just an
implementation detail; it is exactly what this lesson measures.

## The Claims a Token Carries

The modeled token carries two fields: a subject (the caller's identity) and a map of claims —
every piece of information carried about the subject is a key-value pair in this map. There is
a third field, an expiry, but it is not a real timestamp: it is an integer that counts steps,
used later in the lesson. There is no real clock, no real duration, and no real signature; the
token itself is modeled as an ordinary data carrier.

```java
// Token.java — stateless data carrier holding everything about identity; expiry is not real time, it is an integer that counts steps (helper source, no main)
import java.util.*;

record Token(String subject, Map<String, String> claims, int expiry) { }

class Validator {
    // stateless validation: looks only at the token's own fields and the given moment,
    // never accessing any shared state (a revocation list, a session store).
    static boolean valid(Token t, int now) {
        return now <= t.expiry();
    }

    static void show(String label, Object value) {
        System.out.printf("%-42s: %s%n", label, value);
    }
}
```

```java
// ClaimCount.java — the number of claims carried by two tokens is counted; the number of session records kept server-side is zero
import java.util.*;

public class ClaimCount {
    public static void main(String[] args) {
        Token guest = new Token("user-7", Map.of("role", "guest"), 10);
        Token manager = new Token("user-2",
                Map.of("role", "manager", "department", "IT", "level", "3"), 10);

        Validator.show("guest token, number of claims carried", guest.claims().size());
        Validator.show("manager token, number of claims carried", manager.claims().size());
        Validator.show("number of session records kept server-side", 0);
    }
}
```

```
guest token, number of claims carried     : 1
manager token, number of claims carried   : 3
number of session records kept server-side: 0
```

The `guest` token carries one claim, `manager` carries three. This count is not fixed — every
token is as heavy as the information it carries, and there is no structure on the server side
that holds this information a second time. **ST6.** The token carries all information about
identity within its own claims, with no session record kept on the server side at all; this is
the definition of a stateless session. **ST7.** The number of claims carried can vary from
token to token, but the count of records kept on the server side is always zero — there is no
structure here like the `endpoints` map of the `Gatekeeper` measured in the previous lesson,
because there is no caller state left to measure in the first place.

A real token system protects its claims with a signature — a validation step that checks
whether the token's content has been tampered with. This lesson does not write that step;
signatures, keys, and cryptography routines were built in the Identity, Access and Cryptography
course and are not repeated here. Here `Token` is an ordinary record, unsigned and produced
directly; what is measured is not the signature itself but the two questions that remain even
when the signature is correct — how much information a claim carries, and how that information
is read.

The main gain of statelessness in this topic is that the party validating identity needs no
shared record at all. Which component handles a given call becomes irrelevant, because every
component can validate the token on its own, using only the information it already holds — it
never needs to ask a previous component for a record it kept. This property will reappear in
the next two lessons: the distributed configuration lesson will ask whether multiple components
see the same value, and the service discovery lesson will find that which component a call
reaches is never fixed at all. Stateless identity is one reason that variability never becomes
a problem.

## The Source of Validation: Annotation or Call

Whether the claims a token carries are true is a separate question; this lesson measures only
how claims are **read** — where does the answer to "is the role claim manager" come from? Two
paths are modeled. The first is an annotation: a `@RequiredClaim` mark placed on a method
carries the required key and the expected value, and the party that reads and compares it is
not the caller but the check class itself.

```java
// RequiredClaim.java — annotation type stating which claim, with which value, a token must carry for a method to pass (helper source, no main)
import java.lang.annotation.*;

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
@interface RequiredClaim { String name(); String value(); }
```

```java
// ClaimCheck.java — check that reads the annotation and compares it against the token's matching claim (helper source, no main)
import java.lang.reflect.*;

class ClaimCheck {
    static boolean matches(Method m, Token t) {
        RequiredClaim r = m.getAnnotation(RequiredClaim.class);
        if (r == null) return true;
        return r.value().equals(t.claims().get(r.name()));
    }
}
```

```java
// HiddenEndpoints.java — the mark above the panel method states which claim is required (helper source, no main)
class HiddenEndpoints {
    @RequiredClaim(name = "role", value = "manager")
    String panel() { return "panel-data"; }
}
```

The second path is a comparison written directly in the source; no mark is read, no reflection
runs.

```java
// ManualValidation.java — the same question is answered from a comparison written directly in the source (helper source, no main)
class ManualValidation {
    static boolean isManager(Token t) {
        return "manager".equals(t.claims().get("role"));
    }
}
```

```java
// ValidationSource.java — with the same two tokens, the annotation-based and the call-based check are compared, producing the same result
import java.lang.reflect.*;
import java.util.*;

public class ValidationSource {
    public static void main(String[] args) throws Exception {
        Token guest = new Token("user-7", Map.of("role", "guest"), 10);
        Token manager = new Token("user-2", Map.of("role", "manager"), 10);

        Method panel = HiddenEndpoints.class.getDeclaredMethod("panel");

        Validator.show("annotation: panel, guest token", ClaimCheck.matches(panel, guest));
        Validator.show("annotation: panel, manager token", ClaimCheck.matches(panel, manager));
        Validator.show("call: manualValidation, guest token", ManualValidation.isManager(guest));
        Validator.show("call: manualValidation, manager token", ManualValidation.isManager(manager));
    }
}
```

```
annotation: panel, guest token            : false
annotation: panel, manager token          : true
call: manualValidation, guest token       : false
call: manualValidation, manager token     : true
```

The results of the four lines are pairwise identical: `ClaimCheck.matches` and
`ManualValidation.isManager` give the same two answers for the same two tokens. **ST8.** The
same "is the role manager" question can be answered by reading an annotation: the source of the
`panel` method contains neither the word "role" nor a comparison, the decision sits in the
`@RequiredClaim` mark. **ST9.** The same question can also be answered from a comparison written
in the source: the first line of the `isManager` method carries the `claims().get("role")` call
and the comparison directly. Two sources produce the same result, but someone reading the
source of `HiddenEndpoints` never sees the word "manager" at all, while someone reading the
source of `ManualValidation` finds it in the first line.

`ClaimCheck.matches` also has a third case: when there is no `@RequiredClaim` mark at all, the
method returns `true` directly — the same as the default-open setup measured in the previous
lesson, here in a one-line shortcut. This shows the real difference between annotation and
call: annotation-based checking is defined in one place for the whole `HiddenEndpoints` class,
granting access by default when a new method is added and requiring a mark only where needed.
In call-based checking, every new method must write its own comparison; if a method forgets
this line, the omission appears nowhere at all — the source compiles cleanly, runs, and stays
unchecked.

## The Cost of Statelessness: A Revoked Token's Lifetime

`Validator.valid` looks at only two things: the token's own `expiry` field and the `now` value
given to it. This gives rise to the lesson's most limiting measurement: even if a token is
"revoked," if the validator has no revocation list to look at, the revocation record never
affects the token's validity at all. An application can keep its own revocation list on its own
side, but `valid` never accesses that list at all — if it did, it would stop being stateless and
become a validator that looks at a shared structure on every call.

```java
// RevocationAndValidity.java — a revoked token keeps being counted as valid until it expires, because the validator never looks at the revocation list at all
import java.util.*;

public class RevocationAndValidity {
    public static void main(String[] args) {
        Token token = new Token("user-9", Map.of("role", "employee"), 10);
        Set<String> revoked = new HashSet<>();

        Validator.show("before revocation, moment=0, valid", Validator.valid(token, 0));

        revoked.add(token.subject());
        Validator.show("subject in revocation list", revoked.contains(token.subject()));

        int validRemainingSteps = 0;
        for (int moment = 1; moment <= 15; moment++) {
            if (Validator.valid(token, moment)) validRemainingSteps++;
        }
        Validator.show("steps validator counts as valid after revocation", validRemainingSteps);
        Validator.show("moment=11, valid (expired)", Validator.valid(token, 11));
    }
}
```

```
before revocation, moment=0, valid        : true
subject in revocation list                : true
steps validator counts as valid after revocation: 10
moment=11, valid (expired)                : false
```

The `revoked` set holds `token.subject()` — the application made a decision about this subject.
But `Validator.valid` knows nothing about this set's existence; it is never given as a
parameter, never queried at all. **ST10.** Stateless validation looks only at the token's own
fields and the given moment; it has no access to shared state such as a revocation list. **ST11.**
A revoked token keeps being counted as valid until it expires, because the validator never
looks at the revocation list — in this lesson that duration is ten steps, and at the eleventh
step `valid` returns `false` on its own because `expiry` is exceeded. This ten-step window comes
from the token's own `expiry` value; a shorter expiry narrows this window, but never to zero. To
verify this without leaving it as a bare claim, the same measurement is repeated with a shorter
expiry.

```java
// ShortWindow.java — when the expiry is shortened, the number of steps counted as valid after revocation shrinks by the same ratio
import java.util.*;

public class ShortWindow {
    public static void main(String[] args) {
        Token shortLived = new Token("user-11", Map.of("role", "employee"), 3);

        int validRemainingSteps = 0;
        for (int moment = 1; moment <= 15; moment++) {
            if (Validator.valid(shortLived, moment)) validRemainingSteps++;
        }
        Validator.show("expiry=3 token, valid steps counted after revocation", validRemainingSteps);
    }
}
```

```
expiry=3 token, valid steps counted after revocation: 3
```

When `expiry` is given as three instead of ten, the number of steps counted as valid after
revocation also becomes three instead of ten — the window narrows, proportionally. But it never
reaches zero, because the `valid` call itself is still only a number comparison; for the
validator to see revocation instantly, it would need to look at shared state, and adding that
look would take it out of being stateless. This is the lesson's limiting measurement:
statelessness is not free, its cost is measured by the delay of revocation, and this delay —
even as it shrinks with a shorter expiry — never reaches zero.

Placed side by side, the lesson's three measurements repeat the same pattern: information that
sits, or never sits, in the source only turns into a number by being run.

| Measurement | Observed in this lesson |
|---|---|
| number of claims (guest / manager) | 1 / 3 |
| validation source (annotation / call) | both produce the same result |
| valid steps remaining after revocation (expiry 10) | 10 |
| valid steps remaining after revocation (expiry 3) | 3 |

The last two rows run the same mechanism with two different parameters and show that the cost
is not fixed but proportional to the expiry — a relationship that emerges not from a number
written in the source but from comparing two runs.

## Summary

- The token carries information about identity within its own claims with no session record
  kept on the server side; the number of claims carried varies from token to token.
- An authorization question can be answered by reading an annotation (`@RequiredClaim`); the
  decision gives rise to no call expression in the source.
- The same question can also be answered by a comparison written in the source (a call); the
  two sources produce the same result, from different places.
- Stateless validation looks only at the token's own fields and the given moment; it has no
  access to any shared state, such as a revocation list.
- A revoked token keeps being counted as valid until it expires, because the validator never
  looks at the revocation list; this is a cost whose duration in steps can be counted.

## Next Step

These two lessons measured the source of the authorization decision through a single call or a
single token. How many components a container actually brings up, and which parts a given test
sets up, has not yet been asked at all. The next lesson puts the same container into a test
setting and counts two setups side by side: how many components a slice test brings up, how
many a full-context test brings up — and what happens to an annotation left outside the slice.
