The backend’s security layer decides who a request is from and whether that
caller may have what it asked for. It has the shape of Spring Security: you
declare a filter chain as a bean, describe it with HttpSecurity, and the
names and defaults are the ones you already know. This chapter covers the
chain, the ways of signing in, the tokens a server verifies and issues, the
tables it keeps, and how to test it. The differences from Spring
Security are collected in How this differs from Spring Security.
The client side is covered in Authentication and Identity: signing a
Codename One app in against this server with OidcClient, storing its tokens,
authorizing its requests, the device grant and passkeys.
The filter chain
A filter chain is an ordered list of filters that every request it guards
passes through before it reaches a controller. One filter reads the session,
another checks a password or a token, another applies the authorization rules.
You don’t write the filters. You declare a SecurityFilterChain bean, and the
HttpSecurity its method is handed builds the chain from what you ask for:
@Configuration
public class SecurityConfig {
@Bean
@Order(1)
SecurityFilterChain api(HttpSecurity http) {
http.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/orders/**").hasAuthority("SCOPE_orders:read")
.anyRequest().authenticated())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean
SecurityFilterChain pages(HttpSecurity http) {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/css/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
This server has two chains. Requests under /api carry a bearer token and
keep no session. Everything else belongs to a browser that signs in through a
form.
A request is put to the chains in @Order, lowest first. Only the first chain
whose securityMatcher matches the request sees it. A chain without
a securityMatcher guards every request, so it has to come last, and a server
whose catch-all chain isn’t last refuses to start. A bean without @Order
comes after those that have one. A request that no chain matches isn’t guarded
at all.
The server’s own endpoints, which are management, MCP and the telemetry relay, aren’t put to any chain. They keep their own tokens. A WebSocket handshake is a request like any other, and its chain can refuse it.
A chain is an ordinary singleton. The server takes its chains when it starts,
so the build refuses one that’s @Lazy or scoped.
Out of the box a chain guards every request it matches, writes the security headers, keeps the signed-in user in the HTTP session, protects that session against CSRF, and gives a request nobody signed in for an anonymous authentication. It has no way of signing in and no authorization rules until you give it some.
A server without a chain has no security layer
Nothing here is switched on by a dependency. The build links the security
layer only when it finds a SecurityFilterChain bean, so a server without one
has no filters, no login page and no generated password. Its routes are open,
exactly as they were before. That’s the opposite of Spring Boot, where the
starter on the classpath locks every route. See
A server links only the security it declares for what else follows from this.
Who is calling
A handler receives the caller by declaring a parameter:
@RestController
public class AccountApi {
@GetMapping("/api/whoami")
public String whoAmI(Authentication who) {
return who.getName() + " " + who.getAuthorities();
}
@GetMapping("/account/name")
public String name(@AuthenticationPrincipal UserDetails user) {
return user == null ? "nobody" : user.getUsername();
}
}
An Authentication parameter is the authentication of the request. An
@AuthenticationPrincipal parameter is its principal, and it’s null when the
principal isn’t of the declared type. That includes a request nobody signed in
for, whose principal is the text anonymousUser. A handler can also declare a
CsrfToken parameter. The build refuses all three in a module with no chain,
where they would always be null.
Anywhere else, ask the holder:
Authentication who = SecurityContextHolder.getContext().getAuthentication();
String name = who == null ? null : who.getName();
The context belongs to the thread that serves the request, for the length of
that request. It doesn’t follow work handed to another thread: an @Async
method or a @Scheduled job starts with none.
Users and passwords
A chain that takes a password needs to know its users. It finds them in the
application’s UserDetailsService bean. For a demonstration or a test, keep
them in memory:
@Bean
UserDetailsService users() {
return new InMemoryUserDetailsManager(
User.withUsername("ada").password("{noop}secret").roles("ADMIN").build(),
User.withUsername("grace").password("{noop}secret").roles("USER").build());
}
For a real application, keep them in the database. JdbcUserDetailsManager
uses the cn1_users and cn1_authorities tables described in
Keeping it in the database:
@Bean
UserDetailsService users(DataSource dataSource) {
return new JdbcUserDetailsManager(dataSource);
}
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
Both stores compare names without regard to the case of A to Z, and keep
the spelling a user was created with. A store keeps a password as it’s given,
so encode it first:
users.createUser(User.withUsername(name)
.password(encoder.encode(password))
.roles("USER")
.build());
You can also implement UserDetailsService yourself over your own tables, or
hand a chain its users directly with http.userDetailsService(…). A chain
uses the application’s AuthenticationProvider beans or its
AuthenticationManager bean when it has them.
A server with no user store at all can describe one user in its configuration, which suits a first run:
cn1.security.user.name=ada
cn1.security.user.password={noop}secret
cn1.security.user.roles=ADMIN,USER
Password encoders
A stored password starts with the scheme that made it, in braces:
{pbkdf2-sha256}pbkdf2$210000$<salt>$<hash>
{bcrypt}$2a$10$<22 characters of salt><31 characters of hash>
{noop}secret
PasswordEncoderFactories.createDelegatingPasswordEncoder() returns the
encoder that reads that prefix, and a chain uses it when the application
declares no PasswordEncoder bean. It encodes new passwords with
{pbkdf2-sha256}, which is PBKDF2 over HMAC-SHA256 with a random salt and the
round count written into the result.
It also verifies what a Spring application wrote: {bcrypt}, {pbkdf2} and
{pbkdf2@SpringSecurity_v5_8}. A user table brought over from Spring
therefore signs its users in as it is. A password in an older scheme is
re-encoded in the current one the next time its owner signs in, as long as the
user store implements UserDetailsPasswordService. Both stores above do.
A password whose prefix names a scheme the encoder doesn’t know is refused. A
password with no prefix is refused too, unless you call
setDefaultPasswordEncoderForMatches for a store that predates the prefixes.
{noop} stores a password as clear text. It verifies only on a development
profile, which is dev, development, test or local. Anywhere else a
{noop} password matches nothing, and the server says why once on standard
error.
Checking a password takes tens of milliseconds of processor time that can’t be
interrupted. Set cn1.security.password.maxConcurrent to bound how many are
checked at once. A sign-in beyond the bound is answered 503 with Retry-After
immediately, instead of taking a thread from the rest of the server. A value
near the number of processors, less one or two, is reasonable. There’s no bound
unless you set one.
An unknown username still costs one password comparison, so the time an answer takes doesn’t say whether the account exists.
Authorization rules
authorizeHttpRequests says which requests need what:
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/css/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers(AntPathRequestMatcher.antMatcher("DELETE", "/api/**"))
.hasAuthority("orders:delete")
.requestMatchers("/account/password").fullyAuthenticated()
.anyRequest().authenticated());
The rules are asked in the order they’re written, and the first that matches
decides. A request that no rule matches is denied, so most chains end with
anyRequest().
A pattern is an Ant pattern. ? is one character, and * is any run of
characters within one path segment. is any number of whole segments,
including none, so /api/ matches /api, /api/ and /api/users/7.
{name} binds one segment. Matching is case-sensitive, as routing is, and a
trailing slash is part of the path. AntPathRequestMatcher.antMatcher adds an
HTTP method, and RequestMatchers combines matchers with allOf, anyOf and
not.
| Rule | Who passes |
|---|---|
| Everyone, and nobody. |
| Anyone who signed in. |
| Someone who signed in during this session, and wasn’t only recognized by a
|
| Someone recognized by a |
| A caller with the authority |
| A caller with the authority exactly as it’s written. |
| Whoever your own |
A role is an authority with the ROLE_ prefix, and nothing more. roles("ADMIN")
on a user grants ROLE_ADMIN, and hasRole("ADMIN") asks for it. A token’s
scopes become authorities with the SCOPE_ prefix, which is why the first
chain in this chapter asks for SCOPE_orders:read.
Paths the layer refuses
Before any rule of a chain sees a request, the chain checks that its path can’t
mean one thing to a rule and another to the router. A path with a ;
parameter, a backslash, an empty segment, a . or .. segment or a control
character is answered 400 Bad Request. The same goes for a path with an
encoded slash, backslash, percent, dot, semicolon, or NUL.
The check belongs to the chain that claims the request. A request no chain matches is answered exactly as a server with no chain answers it, however its path is written.
A request can’t leave its chain by being misspelled. A path of that kind is
also compared as a proxy or a handler might read it, with its escapes resolved,
its ; parameters dropped and its empty and dot segments removed. If a chain
matches any of those readings, the request is refused. With a chain on
/api/**, both //api/orders and /public/..%2Fapi/orders get a 400.
Matching is case-sensitive, as routing is. /API/orders is a different route
from /api/orders, so it reaches nothing the chain guards.
The answers to a refused request
A request that must sign in is answered by the chain’s entry point. With form
login that’s a redirect to the login page. With HTTP Basic it’s a 401 with a
challenge, when Basic is the only way in or a script made the request. A bearer
token or an API key gets a 401 with the reason in WWW-Authenticate. A chain
with no way of signing in answers 403. A signed-in caller that’s denied gets a
403. Change either one with exceptionHandling:
http.exceptionHandling(handling -> handling
.authenticationEntryPoint(new HttpStatusEntryPoint(401))
.accessDeniedHandler((request, denied) ->
HttpServer.Response.json(403, "{\"error\":\"forbidden\"}")));
Form login, HTTP Basic and sign-out
Form login
With nothing set, formLogin serves a plain login page at GET /login. It
takes the form at POST /login with the fields username and password. It
sends a signed-in user back to the page that asked for the sign-in, or to /,
and a refused one to /login?error.
Name a loginPage to serve your own:
http.formLogin(form -> form
.loginPage("/signin")
.defaultSuccessUrl("/home")
.permitAll())
.logout(logout -> logout
.logoutUrl("/signout")
.logoutSuccessUrl("/")
.permitAll());
The chain then serves no page and takes the form at that same path. Call
permitAll() with a page of your own. Without it, a rule such as
anyRequest().authenticated() redirects the login page to itself.
Only the address of the page that asked for the sign-in is remembered, and
only for a GET that a browser made. A request with a body isn’t replayed
after the user signs in.
HTTP Basic
httpBasic checks the Authorization: Basic header on every request that
carries one. It keeps nothing and starts no session:
http.securityMatcher("/internal/**")
.authorizeHttpRequests(auth -> auth.anyRequest().hasRole("SERVICE"))
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.httpBasic(basic -> basic.realmName("Internal API"));
Sign-out
POST /logout ends the session, forgets who was signed in and redirects to
/login?logout. logout changes the path, the destination and the cookies to
delete, as the form example above shows. The request must be a POST while
CSRF protection is on, so that a link on another site can’t sign a user out.
A chain has sign-out when it has a way of signing in to a session, which is
formLogin, oauth2Login, rememberMe, mfa or webAuthn. Any other chain
has no POST /logout until it calls http.logout(…).
CSRF, sessions and headers
CSRF
A chain that keeps a session requires a CSRF token on every request that isn’t
a GET, HEAD, TRACE or OPTIONS, and answers 403 without it. The token
travels in the _csrf form field or the X-CSRF-TOKEN header. The generated
login page carries it already. Your own pages get it from a CsrfToken handler
parameter or from CsrfFilter.getToken(request).
The token a page is given is masked with random bytes, so every response carries a different string for the same token. The token is replaced when a user signs in.
A single-page application usually wants the token in a cookie that its script
reads and sends back in the X-XSRF-TOKEN header:
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.ignoringRequestMatchers("/webhooks/**"));
The cookie repository also stores the token in the HTTP session. It accepts the cookie only when it matches that session’s token. A sibling subdomain can’t gain access by injecting a known cookie or copying one from another session.
ignoringRequestMatchers leaves out an endpoint that another server calls and
that authenticates some other way. A request authenticated by a bearer token
or an API key is never asked for the token, because a browser doesn’t attach
those to a request another site made it send. csrf.disable() turns the
protection off for a chain whose clients aren’t browsers.
A STATELESS chain has no CSRF filter unless it calls http.csrf(…). It
keeps nothing a forged request could ride on. A stateless chain that takes
HTTP Basic credentials from browsers should ask for it.
Sessions
sessionManagement says whether a chain may use the HTTP session that
Backend sessions describes:
http.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.sessionFixation(fixation -> fixation.newSession()));
| Policy | What the chain does |
|---|---|
| Starts a session when it needs one: at sign-in, or to remember where an anonymous request was going. The default. |
| Gives every request under the chain a session. |
| Never starts a session, and uses one that’s already there. |
| Neither starts nor reads a session. Each request carries its own credentials. |
At sign-in the chain changes the session id and keeps the attributes, which is
the changeSessionId() call that the sessions chapter asks you to make by
hand. sessionFixation chooses otherwise: newSession() starts an empty
session, and none() leaves the session as it is.
Security headers
Every chain puts these on its responses:
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Strict-Transport-Security: max-age=31536000 ; includeSubDomains
The cache headers are left out when the handler set any of the three itself.
Strict-Transport-Security is sent only for a request that arrived over TLS.
A header the handler set is never replaced. headers changes the defaults and
adds a Content-Security-Policy, which isn’t sent unless you configure it:
http.headers(headers -> headers
.frameOptions(frame -> frame.sameOrigin())
.contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'self'")));
Method security
Rules about paths guard the door. Method security guards the service behind it, whoever calls it:
@Component
@RolesAllowed("ADMIN")
public class Reports {
public String revenue() {
return "..."; // the class's rule: ROLE_ADMIN
}
@PreAuthorize("hasRole('ADMIN') or #owner == authentication.name")
public String activity(String owner) {
return "...";
}
@PreAuthorize("@documentPermissions.canEdit(authentication, #id)")
public void rename(@P("id") long documentId, String title) {
// ...
}
@PermitAll
public String status() {
return "ok";
}
}
| Annotation | Who may call |
|---|---|
| A caller for whom the expression holds. |
| A caller with one of the authorities, each written in full. |
| A caller with one of the roles. |
| Anyone, and nobody. |
On a class, an annotation covers every public method. On a method, it replaces
the one on the class. A caller who hasn’t signed in gets an
InsufficientAuthenticationException, which a chain answers with its sign-in
challenge. A signed-in caller who’s refused gets an AccessDeniedException,
which is a 403.
The expression subset
The backend has no expression language at run time. The build compiles each
@PreAuthorize expression into plain Java, so an expression costs what an
if costs, and a mistake in one is a build error. An expression may contain:
hasRole('X'),hasAnyRole('X', 'Y'),hasAuthority('X')andhasAnyAuthority('X', 'Y').isAuthenticated(),isAnonymous(),isFullyAuthenticated(),isRememberMe(),permitAllanddenyAll.and,orandnot, also written&&,||and!, and parentheses.authentication.nameandprincipal.username, compared with==or!=to a string literal or to aStringparameter written#name.A call on a bean, written
@beanName.method(…).
A parameter is named as the source names it when the class is compiled with
debug information or -parameters. Otherwise name it with @P, as rename
does above.
Calling a bean
Anything the subset can’t say goes in a bean method:
@Component
public class DocumentPermissions {
public boolean canEdit(Authentication who, long documentId) {
return who != null && ownerOf(documentId).equals(who.getName());
}
The arguments of the call can be authentication, principal, a parameter
written #name, or a string, whole number or boolean literal. The build
checks the call against the bean’s class: the method has to exist, be public
in a public class, and return boolean. The bean has to be a singleton. Its
name is the class’s simple name with the first letter in lower case, unless
@Component names it.
Where the build draws the line
What the build can’t compile, it refuses with the reason and the fix:
@PreAuthorize("hasPermission(#id, 'read')") on Reports.read: hasPermission(...) is not
supported: there is no PermissionEvaluator. Write the decision as a bean method and call
it: @permissions.canRead(authentication, #id).
@PreAuthorize("#order.owner.id == authentication.name") on Reports.open: #order.owner is a
property chain, which is not supported: the build compiles expressions and does not read
properties by name. Pass the parameter to a bean method and read it there:
@bean.method(authentication, #order).
Reports uses method security, and this module has no SecurityFilterChain bean: without a
chain nobody ever signs in, so every guarded method would refuse every caller. Declare
one -- a @Bean method that takes an HttpSecurity and returns http.build() -- or remove
the annotations.
The build also refuses returnObject, T(…), @PostAuthorize, @PreFilter
and @PostFilter, two security annotations on one element, and an annotation
on an interface method or an abstract method.
Because the build rewrites the method itself instead of wrapping the object in
a proxy, the check runs however the method is called: from another bean, from
this, on a private method, or on an object made with new. In Spring a call
through this skips the check. For an @Async method the check runs on the
caller’s thread, before the work is handed off.
A JWT resource server
A resource server takes a bearer token on each request and verifies it as a
JWT. oauth2ResourceServer turns it on, as the api chain at the top of this
chapter does, and two properties tell it whose tokens to accept:
cn1.security.oauth2.resourceserver.jwt.issuer-uri=https://id.example.com
cn1.security.oauth2.resourceserver.jwt.audiences=orders-api
With an issuer-uri, the server reads the issuer’s metadata from its
.well-known address when it starts, and refuses metadata that names another
issuer. It fetches the keys from the metadata’s jwks_uri when the first
token arrives, and requires every token’s iss to be that issuer.
Set audiences too. Without it, a token the issuer made for another
application is accepted here.
A token with a bad signature, an expired token, or one from another issuer is
answered 401 with the reason in WWW-Authenticate. A good token that doesn’t
grant enough is answered 403 with insufficient_scope. No session is started
for a request that carries a token.
A request with no token is answered 401 with a challenge and no error, because nothing was wrong with a token it didn’t send. When the rule that refused the request asks for a scope, the challenge names it, and so does the 403:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer scope="orders:read"
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
error_description="The request requires higher privileges than provided by the access token.",
error_uri="https://tools.ietf.org/html/rfc6750#section-3.1", scope="orders:read"
realmName on oauth2ResourceServer adds a realm to every challenge.
Keys
For an issuer that publishes no metadata, set jwk-set-uri instead, or
public-key-location for a PEM file holding the one public key. For anything
the properties can’t say, declare a JwtDecoder bean:
@Bean
JwtDecoder jwtDecoder() {
DefaultJwtDecoder decoder = DefaultJwtDecoder
.withJwkSetUri("https://id.example.com/oauth2/jwks")
.jwsAlgorithms(SignatureAlgorithm.RS256, SignatureAlgorithm.ES256)
.build();
decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(
new JwtIssuerValidator("https://id.example.com"),
new JwtAudienceValidator("orders-api")));
return decoder;
}
A fetched key set is kept for five minutes. A token that names a key the set doesn’t have makes the server fetch early, which is how a rotated key is picked up, but never more than once in 30 seconds. When a fetch fails, the server goes on using the keys it has.
Use an https address for the keys. They’re what every token is trusted by.
What’s verified
The token’s alg header never chooses the algorithm. A decoder is built with
the algorithms it accepts, RS256 unless told otherwise, and holds keys of a
matching kind. A token whose header disagrees with either is refused, so
alg: none fails, and so does a token signed with HMAC using an RSA public
key as the secret. The supported algorithms are RS256, RS384, RS512,
PS256, ES256 and ES384, and HS256, HS384 and HS512 for a shared
secret.
The default validators check exp and nbf with a minute’s allowance for
clocks that disagree. A decoder checks iss and aud only when it’s told
what they should be. JwtClaimValidator tests any other claim.
From scopes to authorities
A token’s authorities come from its scope claim, or else scp, each with
the SCOPE_ prefix. A token with "scope": "orders:read orders:write" is
granted SCOPE_orders:read and SCOPE_orders:write. Its name is the sub
claim. A converter changes either one:
JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
roles.setAuthoritiesClaimName("roles"); // ["ADMIN", "USER"]
roles.setAuthorityPrefix("ROLE_"); // so hasRole("ADMIN") matches
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(roles);
converter.setPrincipalClaimName("preferred_username");
http.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
jwt.jwtAuthenticationConverter(converter)));
Several issuers
A server that trusts more than one issuer routes each token by its iss:
http.oauth2ResourceServer(oauth2 -> oauth2.authenticationManagerResolver(
JwtIssuerAuthenticationManagerResolver.fromTrustedIssuers(
"https://login.example.com", "https://partners.example.com")));
A token that names an issuer not on the list is refused without a request being made anywhere.
API keys
An API key is a long-lived secret that a program presents instead of signing
in. apiKey turns it on:
http.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/deploy/**").hasAuthority("SCOPE_deploy")
.anyRequest().authenticated())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.apiKey(Customizer.withDefaults());
A client sends its key in either of two headers:
curl -H "X-API-Key: cn1_your-key" https://api.example.com/api/deploy/status
curl -H "Authorization: Bearer cn1_your-key" https://api.example.com/api/deploy/status
The chain looks keys up in the application’s ApiKeyRepository bean. That’s a
JdbcApiKeyRepository over the cn1_api_key table, an
InMemoryApiKeyRepository, or your own. Make a key with ApiKeyGenerator:
GeneratedApiKey made = new ApiKeyGenerator().generate("ci-bot", "deploy", "read");
repository.save(made.getApiKey()); // the hash, the owner and the scopes
String shownOnce = made.getPlaintext(); // cn1_...; it can't be read back later
A key is a prefix followed by 256 random bits. Only its SHA-256 is stored, so a
copy of the table isn’t a set of working keys, and the key’s text exists only
at the moment it’s made. The stored record keeps the prefix and the last four
characters, which is enough to show someone which key a row is. revoke
withdraws a key.
A request authenticated by a key is named after the key’s owner, and each scope
is the authority SCOPE_ and its name. Those are the authorities a token with
the same scopes gets, so one set of rules covers both. On a chain that also has
oauth2ResourceServer, a bearer value that starts with the key prefix is an
API key and any other is a token. The prefix is cn1_ unless you change it,
and the chain’s prefix has to be the one the keys were generated with.
Rate limiting
rateLimit bounds how often the requests a matcher matches may be made under
one key. A request over the limit is answered 429 with Retry-After:
http.rateLimit(AntPathRequestMatcher.antMatcher("POST", "/login"),
RateLimitKeys.clientAddress(), new JdbcRateLimiter(dataSource, "login", 5, 60));
http.rateLimit("/api/**",
RateLimitKeys.firstOf(RateLimitKeys.apiKeyId(), RateLimitKeys.principal()),
new InMemoryRateLimiter(600, 60));
RateLimitKeys has the usual keys: the client’s address, the name of who is
signed in, the session id and the API key’s id. firstOf takes the first one
a request has. A limit whose key the request has from the start runs before
anything else in the chain, so a refused request costs no password hash and no
query. A limit keyed by who signed in runs once that’s known. A request with
no key for a rule isn’t limited by it.
Give each rule its own limiter. Two rules that share one share its counts.
InMemoryRateLimiter is a token bucket, and its counts are per process. A
server that runs as several instances has one count in each, so a client is
allowed up to the limit times the number of instances. The counts are also
gone when the process restarts. That’s usually fine for a limit that keeps
load down. A limit that’s a security bound, such as attempts at a password,
needs JdbcRateLimiter, which counts in the cn1_rate_limit table so that
every process shares one limit. It costs a statement or two per request. When
the database can’t be reached it refuses nobody.
The limits the layer keeps itself
Two parts of the layer count something without being asked. mfa counts the
wrong codes entered for each user, and the authorization server counts the
device codes each user tries at the verification page.
| What’s counted | Keys | Limit | Window |
|---|---|---|---|
Wrong one-time codes for a user, from everywhere |
| 5 | 300 seconds |
Wrong codes for a user from one client address |
| Half of | The same window |
Device codes tried |
| 10 | 300 seconds |
With no RateLimiter bean, both are counted in the process. Declare one
RateLimiter bean and they’re counted wherever that bean counts. A
JdbcRateLimiter bean makes each a single count for every instance:
@Bean
RateLimiter limits(DataSource dataSource) {
return new JdbcRateLimiter(dataSource, "api", 600, 60);
}
The bean’s own limit doesn’t apply to them. The layer asks the bean for a separate limiter for each purpose, with the numbers in the table, so a bean that throttles an API at 600 requests a minute doesn’t allow 600 guesses at a code.
attemptLimiter on mfa and deviceVerificationRateLimiter on the
authorization server take a limiter directly, and it counts by its own limit.
A bean of your own RateLimiter class that doesn’t implement derive does
the same. Setting the keys as well is then refused when the server starts,
because they’d decide nothing.
An application with several RateLimiter beans has to say which one these two
use. The bean marked @Primary is used, as it would be for an injection. With
no bean marked, or more than one, the server doesn’t start, and the error names
the beans. It never falls back to counting in the process, because that would
look the same as the shared count you asked for. There are two ways to choose:
mark one bean @Primary, or inject the one you mean into the chain’s method,
with @Qualifier and its name, and pass it to attemptLimiter or
deviceVerificationRateLimiter. A rateLimit rule that names no limiter
follows the same rule.
What the second factor’s limits promise
A wrong code is counted twice: once for the user, whatever address and session it came from, and once for the user at the client’s address. An IPv6 address counts by its first 64 bits, because one subscriber has all the addresses behind them. A right code isn’t counted. A sign-in that completes clears what was counted against that user, whether it was completed with a one-time code, a recovery code or a passkey that verified the user.
Recovery codes are counted apart from one-time codes, and only at the client’s address. That keeps a recovery code usable when the one-time codes aren’t.
With the default numbers, this is what somebody who has a user’s password and not their second factor can and can’t do:
They get five wrong one-time codes in five minutes in total. Signing in again with the password, starting another session or using another address doesn’t buy more. At most three of the five can come from one address.
They get three wrong recovery codes in five minutes from each address, with no total for the user. A recovery code is ten characters from an alphabet of 31 and a user has ten of them, so one guess is right about once in 80 trillion tries.
They can’t keep the user out from one address. When that address has used its three, it’s refused there and takes nothing more from the user’s five. Two are left for everyone else, and a right code needs one.
From two addresses or more they can use up all five. One-time codes are then refused for that user from everywhere until the window has passed. The user can still sign in with a recovery code from an address the attacker isn’t using, or with a passkey, which isn’t counted at all. Either one clears the count. The attacker can run the count up again, so this lasts until the password is changed. A run of
429answers for one user is the sign that it needs changing.At the user’s own address they can use up both counts, the one-time codes' and the recovery codes'. A passkey is then the way in.
The last point matters behind a proxy. A server that isn’t told to believe the
forwarding headers sees the proxy’s address for every client, so every client
is at the user’s own address. Set cn1.server.forwardHeaders; see
Client addresses behind a proxy.
Setting cn1.security.mfa.attemptsPerAddress to the same number as
cn1.security.mfa.attempts gives up the third point: one address that knows
the password can then keep the user out. A larger number is refused when the
server starts.
The window depends on the limiter. The count kept in the process hands attempts
back evenly, so after five at once it allows one more each minute. A
JdbcRateLimiter counts five from the first of each window.
A limiter you pass to attemptLimiter counts both by its own single limit, so
one address may use every attempt a user has. Pass two limiters,
attemptLimiter(perUser, perAddress), to keep the promises in the list.
Clearing a count uses RateLimiter.reset. A limiter of your own that doesn’t
implement it clears nothing, and a right code then costs an attempt.
Client addresses behind a proxy
Behind a load balancer every connection comes from the load balancer, so
RateLimitKeys.clientAddress() would be one key for every client. The real
address arrives in X-Forwarded-For, and anybody can send that header. The
server therefore believes it only when you say so, and only from a proxy it
trusts:
cn1.server.forwardHeaders=true
cn1.server.trustedProxies=10.0.0.0/8,192.0.2.10
cn1.server.forwardHeaders is false unless set. cn1.server.trustedProxies
is a list of addresses and CIDR ranges. An omitted or empty list trusts no
peers, including loopback and private addresses. List only the deployment’s
actual reverse proxies. X-Forwarded-For is read from the right,
past every trusted proxy, so what a client wrote at the left end is never
reached.
The same setting decides whether X-Forwarded-Proto is believed. That’s what
request.isSecure() answers with behind a proxy that terminates TLS, and it
decides whether Strict-Transport-Security is sent. request.getRemoteAddress()
is the client’s address under these rules, and request.getPeerAddress() is
always the other end of the connection.
Signing in through another provider
oauth2Login signs users in through Google, GitHub, Microsoft, Apple or any
OAuth2 or OpenID Connect provider:
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2Login(Customizer.withDefaults());
The providers come from the configuration:
cn1.security.oauth2.client.registration.google.client-id=your-google-client-id
cn1.security.oauth2.client.registration.google.client-secret=your-google-client-secret
cn1.security.oauth2.client.registration.github.client-id=your-github-client-id
cn1.security.oauth2.client.registration.github.client-secret=your-github-client-secret
cn1.security.oauth2.client.registration.acme.client-id=web
cn1.security.oauth2.client.registration.acme.client-secret=your-acme-client-secret
cn1.security.oauth2.client.registration.acme.scope=openid,profile,email
cn1.security.oauth2.client.registration.acme.provider=acme-id
cn1.security.oauth2.client.provider.acme-id.issuer-uri=https://id.example.com
A registration called google, github or microsoft needs only the client
id and secret the provider issued. Any other provider is described by a
cn1.security.oauth2.client.provider.<id> block. Give it an issuer-uri and
the endpoints are read from the issuer’s metadata. Otherwise name them with
authorization-uri, token-uri, user-info-uri, jwk-set-uri and
user-name-attribute. Under a registration, the keys are client-id,
client-secret, client-authentication-method, scope, redirect-uri,
client-name, response-mode and provider.
A sign-in starts at GET /oauth2/authorization/{registrationId}, which sends
the browser to the provider. It ends at /login/oauth2/code/{registrationId},
which is the redirect address to register with the provider. A chain with no
login page of its own serves one at GET /login with a link for each
provider. When there’s only one provider, a request that must sign in goes
straight to it.
Every request to a provider carries a state, a PKCE challenge and, with
OpenID Connect, a nonce. An ID token is accepted only when its signature
verifies under the provider’s published keys and its iss, aud, exp and
nonce are right. After the sign-in, the user goes back to the page that
asked for it or to defaultSuccessUrl. Nothing the provider or the request
says chooses the address.
The Microsoft registration uses the multi-tenant common endpoints, which
accept accounts of any organization. An application that admits one tenant
should also check the tid claim.
Sign in with Apple
Apple’s client secret isn’t a text Apple issues. It’s a token the server signs
with the .p8 key from the developer account, so Sign in with Apple is
declared in code, beside the registrations the configuration holds:
@Bean
ClientRegistrationRepository providers(Config config) throws IOException {
List<ClientRegistration> all =
new ArrayList<ClientRegistration>(ClientRegistrations.fromConfig(config));
all.add(CommonOAuth2Provider.APPLE.getBuilder("apple")
.clientId("com.example.web") // the Services ID
.clientSecretSupplier(AppleClientSecret.fromFile(
config.get("apple.team-id"), config.get("apple.key-id"),
config.get("apple.key-file")))
.build());
return new InMemoryClientRegistrationRepository(all);
}
A ClientRegistrationRepository bean replaces the registrations read from the
configuration, which is why this one adds them itself.
A registration for Apple in the configuration is refused when the server
starts, whether it names the apple provider or spells out Apple’s endpoints.
A client-secret setting could never be right for it, and the error shows the
bean to declare instead.
Apple answers with a form that the browser posts from Apple’s page, and a
browser doesn’t send the session cookie with such a request. The pending
sign-in is therefore kept in a signed cookie of its own. Its __Host- name,
Path=/, Secure flag, and absence of a Domain attribute prevent a sibling
subdomain from injecting a parent-domain cookie. The old unprefixed cookie
isn’t accepted; a sign-in started with that cookie must be started again.
Set cn1.security.oauth2.client.cookie-secret, at least 32 characters, to the
same value on every instance. Without it each process makes its own key when
it starts.
Account linking
By default, a user who signs in through a provider is that provider’s user and
nothing more. LinkingOAuth2UserService ties them to one of the application’s
own users, so the same person has the same account and the same roles however
they sign in:
@Bean
FederatedIdentityRepository identities(DataSource dataSource) {
return new JdbcFederatedIdentityRepository(dataSource);
}
@Bean
SecurityFilterChain web(HttpSecurity http, FederatedIdentityRepository identities,
UserDetailsService users) {
LinkingOAuth2UserService linking = new LinkingOAuth2UserService(identities, users);
linking.setCreateUsers(true);
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2Login(oauth2 -> oauth2
.userService(linking)
.oidcUserService(linking.oidc()));
return http.build();
}
It decides who the provider’s user is in this order:
An identity that’s already tied to a local user signs in as that user. An identity is the provider and the provider’s subject, never the email address, which can change hands.
Otherwise the provider must say that it has verified the user’s email address. An address it doesn’t vouch for is refused with
email_not_verified. GitHub’s user carries no such flag, so GitHub is asked for its list of addresses, and the one marked both primary and verified is used.A verified address that’s the name of a local user ties the identity to that user.
A verified address that nobody here has makes a new local user when
setCreateUsers(true)allows it and the user store is aUserDetailsManager. Otherwise the sign-in is refused withaccount_not_found.
A user made this way has a password nobody knows, and signs in through the
provider until they set one. The identities are kept in a
FederatedIdentityRepository, in memory or in the cn1_federated_identity
table.
The authorization server
authorizationServer makes the server an OAuth2 authorization server and
OpenID Connect provider: the one that signs users in for clients and issues
them tokens. A Codename One app signs in against it with a system browser and
PKCE, and then calls your API with the access token.
@Bean
SecurityFilterChain web(HttpSecurity http) {
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.authorizationServer(Customizer.withDefaults());
return http.build();
}
How a user signs in is whatever else the chain declares: a form, another
provider, a second factor, a passkey. A browser that arrives signed out is
sent to sign in and comes back to the request it made. A client that isn’t a
browser is answered 401 with login_required.
| Endpoint | What it does |
|---|---|
| The authorization endpoint, for the signed-in user. |
| The token endpoint. |
| The public halves of the signing keys. |
| Revokes a token. |
| Starts a device grant. |
| The page where the signed-in user types the device’s code. |
| The user’s claims, for an access token granted |
| The metadata a client discovers the endpoints from. |
The server needs to know its issuer, which is the address clients reach it at, with no trailing slash:
cn1.security.authorizationserver.issuer=https://id.example.com
cn1.security.authorizationserver.jwk.keys=/etc/acme/signing-new.pem,/etc/acme/signing-old.pem
The issuer is the iss of every token and the base of every address in the
metadata. Outside a development profile the server doesn’t start without it.
On a development profile it’s taken from the request. settings sets the
issuer in code and moves the endpoints, and tokenCustomizer changes the
claims of a token before it’s signed:
http.authorizationServer(server -> server
.settings(AuthorizationServerSettings.builder()
.issuer("https://id.example.com")
.build())
.tokenCustomizer(context -> {
if (OAuth2TokenContext.ACCESS_TOKEN.equals(context.getTokenType())) {
context.getClaims().claim("tenant", "acme");
}
}));
Registered clients
The clients are the application’s RegisteredClientRepository bean, and the
server doesn’t start without one:
@Bean
RegisteredClientRepository clients(PasswordEncoder encoder, Config config)
throws IOException {
RegisteredClient app = RegisteredClient.withId("mobile")
.clientId("acme-app")
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE) // public: PKCE
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
.redirectUri("com.acme.app:/oauth2redirect")
.scope("openid").scope("profile").scope("orders:read")
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(300)
.refreshTokenTimeToLive(30L * 24 * 3600)
.build())
.build();
RegisteredClient reports = RegisteredClient.withId("reports")
.clientId("nightly-reports")
.clientSecret(encoder.encode(config.get("reports.client-secret")))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.scope("orders:read")
.build();
return new InMemoryRegisteredClientRepository(app, reports);
}
The first client is public. A mobile app can’t keep a secret, so it
authenticates with nothing and proves with PKCE that the code it redeems is
the one it asked for. PKCE is required of a public client, and only S256 is
accepted.
The second client is confidential. It has a secret, which is stored as a
PasswordEncoder wrote it and compared through the application’s
PasswordEncoder bean, or the one given to clientSecretEncoder(…). With
neither, a server whose clients are declared in memory doesn’t start if one of
them has a secret, and the error names the client. A server that keeps its
clients in a table can’t know them all when it starts. It warns once at
start-up, and answers a client that presents a secret with invalid_client.
A redirect address is matched whole, character for character. There are no
wildcards. The one exception is an http address on 127.0.0.1 or [::1],
which may be asked for with any port, because a desktop application can’t know
which port it will get. A request whose client or redirect address is wrong is
answered 400 and never a redirect.
A client is never granted a scope it wasn’t registered with. There’s no consent page: registering a client with a scope is the consent.
JdbcRegisteredClientRepository keeps the clients in the
cn1_oauth2_registered_client table instead.
Grants
Authorization code. A code works once, for five minutes. Presenting it again later revokes what its use issued.
Refresh token. Opaque, stored as a hash, and issued to public clients too.
Client credentials. For a client with a secret, acting as itself:
curl -u nightly-reports:your-client-secret \ -d grant_type=client_credentials -d scope=orders:read \ https://id.example.com/oauth2/tokenDevice code. For a device with no browser or keyboard, described below.
Refresh tokens
A refresh token is replaced every time it’s used. Using one that was already replaced means two parties hold it, so the server revokes the whole grant. That rotation is what protects a public client, which has no secret to present with the token. A client that refreshes twice at the same moment is allowed ten seconds in which the second request is refused and nothing is revoked.
TokenSettings sets the lifetimes for each client. Unless set, an
authorization code, an access token and a device code last 5 minutes, an ID
token 30 minutes, and a refresh token 60 minutes from its last use.
reuseRefreshTokens(true) turns rotation off.
Token issuance and grant revocation are coordinated by the store. Once a
grant is removed, a refresh request can’t recreate it. Custom stores must
implement OAuth2AuthorizationService.issueTokens: check the active grant,
record or extend the refresh token, and extend the grant in one operation
that’s atomic with remove. The default implementation refuses issuance.
Client-credentials access tokens also have independent stored grants. Their
owner can remove a grant through /oauth2/revoke; revoking one token doesn’t
revoke tokens issued by other client-credentials requests. They never include
refresh tokens, even when the client also supports the refresh grant.
A resource server that must reject revoked JWTs immediately needs access to
the same authorization store. Add OAuth2AuthorizationValidator(store, issuer)
to its decoder’s JwtValidators.createDefaultWithValidators(…), alongside
its audience validator. This checks the grant on each request after signature
verification. A resource server that verifies JWTs offline continues accepting
them until expiry; fetching public keys alone doesn’t convey revocation state.
Device polling uses OAuth2AuthorizationService.pollToken to check the last
accepted poll and claim the next interval atomically. Custom stores must
implement this operation before enabling the device grant; concurrent polls
must not each claim the same interval.
The grants are kept by the application’s OAuth2AuthorizationService bean:
@Bean
OAuth2AuthorizationService authorizations(DataSource dataSource) {
return new JdbcOAuth2AuthorizationService(dataSource);
}
Without one, the grants are kept in the process, and a line at start-up says so. Refresh tokens then don’t survive a restart and aren’t shared between instances.
Keys and rotation
Tokens are signed with the keys in cn1.security.authorizationserver.jwk.keys,
a list of PEM files that each hold an RSA or a P-256 private key. Make one
with:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out signing.pem
The first key signs every new token, with RS256 for an RSA key and ES256
for a P-256 key. Every key is published at /oauth2/jwks. To rotate,
put the new key first and keep the old one in the list for as long as its
tokens live. A resource server picks the new key up on its own, as
A JWT resource server describes.
A key file protected by a passphrase is refused. On a development profile with no key set, the server makes a key when it starts and warns you, and every restart then invalidates every token. Anywhere else, a missing key stops the server from starting.
An application that signs tokens of its own declares the keys and an encoder as beans, and the authorization server uses the same ones:
@Bean
JwkSource signingKeys(Config config) throws IOException {
return AuthorizationServerKeys.load(config);
}
@Bean
JwtEncoder jwtEncoder(JwkSource keys) {
return new DefaultJwtEncoder(keys);
}
The device grant
A television or a command-line tool can’t show a login page. It asks the server for a code, and then polls the token endpoint while the user approves it somewhere else:
curl -d client_id=acme-app -d "scope=openid orders:read" \
https://id.example.com/oauth2/device_authorization
curl -d grant_type=urn:ietf:params:oauth:grant-type:device_code \
-d client_id=acme-app -d device_code=the-device-code \
https://id.example.com/oauth2/token
The first answer carries a device_code, a user_code of eight letters, a
verification_uri and the interval to poll at. The user opens the
verification page on a phone or a computer, signs in, types the code, and is
asked by name whether to let that client in. Until they answer, the device’s
polling is answered authorization_pending, slow_down, access_denied or
expired_token.
The verification endpoint also answers in JSON, for an application that
signed the user in itself and wants its own approval screen. A request gets
the JSON form when its Accept header names application/json and not
text/html, or when its body is application/json:
curl -b cookies.txt -H "Accept: application/json" \
https://id.example.com/oauth2/device_verification
curl -b cookies.txt -H "Content-Type: application/json" -H "X-CSRF-TOKEN: the-token" \
-d '{"user_code":"BCDF-GHJK"}' \
https://id.example.com/oauth2/device_verification
curl -b cookies.txt -H "Content-Type: application/json" -H "X-CSRF-TOKEN: the-token" \
-d '{"user_code":"BCDF-GHJK","ticket":"the-ticket","decision":"approve"}' \
https://id.example.com/oauth2/device_verification
The first request answers with the CSRF token to send back, when the chain has
CSRF protection. The second looks the code up and answers with client_id,
client_name, scope, principal and a ticket. The third carries that
ticket with "decision": "approve" or "deny", and is answered
{"status":"approved"} or {"status":"denied"}.
Nothing is relaxed for the JSON form. It needs a session, as the page does: the
ticket is kept in the session between the lookup and the answer, so a client
that doesn’t send the session cookie back can’t approve a device. The user has
to be signed in, and a
request that isn’t is answered 401 with login_required instead of a
redirect. The chain’s CSRF protection covers both posts. An answer counts only
with the ticket from the lookup in the same session, and a ticket works once.
A code that isn’t valid is invalid_grant, an answer without its ticket is
invalid_request, and too many tries is 429 with slow_down.
One user may try ten codes in five minutes at the verification page. Change
the numbers with
cn1.security.authorizationserver.device.verificationAttempts and
cn1.security.authorizationserver.device.verificationWindowSeconds. See
The limits the layer keeps itself for where the tries are counted.
What a resource server sees
An access token is a JWT of type at+jwt. Its payload looks like this:
{
"iss": "https://id.example.com",
"sub": "ada",
"aud": "https://api.example.com",
"client_id": "acme-app",
"scope": "openid profile orders:read",
"iat": 1767225600,
"nbf": 1767225600,
"exp": 1767225900,
"jti": "..."
}
sub is the user’s name, or the client’s id for the client credentials grant.
client_id is the client the token was issued to. scope becomes the
SCOPE_ authorities that A JWT resource server describes.
aud is the resource server the token is for, and it’s the value that
resource server lists in its audiences. A client names the resource server
with the resource parameter of its request, at the authorization endpoint,
the device authorization endpoint, or the token endpoint. A request can carry
the parameter more than once, and aud is then a list. At the token endpoint
the parameter can only narrow what the grant was made for.
A request that names no resource gets the default audience. That’s the issuer,
unless defaultAudience on the settings or
cn1.security.authorizationserver.audience names another:
http.authorizationServer(as -> as.settings(AuthorizationServerSettings.builder()
.issuer("https://id.example.com")
.defaultAudience("https://api.example.com")
.build()));
A client registered with resource(…) can ask for those resources and no
others. Anything else is answered invalid_target. A client registered with
none can name any resource, so give a client a list when it shouldn’t choose.
/userinfo answers a token that’s for this server: one with the default
audience, or one whose request named the address of /userinfo as a resource.
A token made only for other resource servers gets a 401 there, so a service
that’s handed such a token can’t use it to read the user’s profile. A client
that wants one token for a resource server and for /userinfo names both.
An ID token is for the client, so its aud is the client’s id.
It also carries nonce, auth_time, azp and at_hash. Without a
userInfoMapper, the profile scope yields preferred_username and the
email scope yields nothing, because the server doesn’t know that a username
is an address.
Authorizing a client or approving a device requires a sign-in stored in the current HTTP session. API keys, bearer tokens, HTTP Basic credentials and authentication restored from a remembered sign-in don’t satisfy this check. Sign in through the chain’s form, OAuth2 login or passkey flow first.
The authorization endpoint returns login_required for prompt=login,
max_age=0, or a session whose last credential check is older than max_age.
It also refuses a bounded-age request when the session has no recorded sign-in
time. The client must arrange a fresh sign-in before retrying. The
server issues no code when fresh authentication is required. A completed sign-in
updates auth_time, including a sign-in completed with a second factor.
An access token is verified by its signature alone. Revoking a grant stops its
refresh token and its answers at /userinfo, but an access token that’s
already issued stays good until it expires. Keep access tokens short.
A second factor
mfa asks a user who has enrolled an authenticator app for a one-time code
after their password:
@Bean
TotpService totp(DataSource dataSource, Config config) {
return new TotpService(JdbcTotpRepository.fromConfig(dataSource, config), "Acme");
}
@Bean
RecoveryCodeService recoveryCodes(DataSource dataSource) {
return new RecoveryCodeService(new JdbcRecoveryCodeRepository(dataSource));
}
@Bean
SecurityFilterChain web(HttpSecurity http) {
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.mfa(Customizer.withDefaults());
return http.build();
}
The password of an enrolled user no longer signs them in. It’s accepted, the
request stays anonymous, and the user is sent to /login/mfa, which asks for
the code. Posting a right one there signs them in. A user who hasn’t enrolled
signs in as before. The chain serves a plain page at GET /login/mfa unless
secondFactorPage names your own. The user has five minutes to enter the
code. Five wrong codes are allowed for the user in five minutes, and three of
them from one address. See What the second factor’s limits promise for what that
does and doesn’t stop, and The limits the layer keeps itself to change the
numbers or to count across instances.
What counts as a sign-in
A declared second factor holds for every way a user of that chain can present a first one. None of it affects a user who hasn’t enrolled.
| Mechanism | For a user who has a second factor |
|---|---|
| The password is accepted and the sign-in waits for the code. |
| The other provider’s sign-in is accepted and waits for the code here. |
| A passkey whose authenticator verified the user is two factors already, and signs in. One that didn’t waits for the code. |
| Refused with a 401. Credentials sent with every request have no step to present a code in. |
| The cookie signs the user in only if the sign-in that issued it passed the
second factor. A cookie issued for a password alone, before the user
enrolled, is withdrawn. A |
| Accepted. A token or a key isn’t a user signing in. It was issued by a sign-in that already went through the chain’s factors, or by an administrator, and is revoked on its own terms. |
The 401 for HTTP Basic says why, in the body and as
error="second_factor_required" in the challenge. A wrong password gets the
ordinary challenge, so the answer tells nothing to a caller who doesn’t know
the password. A chain whose Basic callers are scripts on a trusted network
can opt out with httpBasic(basic → basic.secondFactorExempt()).
Enrolling
Enrollment takes two steps, so that nobody is locked out by an app that was never set up. First make a secret and show it to the user:
TotpEnrollment enrollment = totp.beginEnrollment(username);
String forQrCode = enrollment.getOtpauthUri(); // otpauth://totp/...
String forTyping = enrollment.getSecret(); // the same secret, in Base32
Then take a code from their app. Only then does the user have a second factor:
if (!totp.confirmEnrollment(username, code)) {
return null; // a wrong code: nothing changed
}
List<String> shownOnce = recoveryCodes.generate(username);
Codes are six digits that change every 30 seconds, which is what authenticator apps expect. One step either side of the current one is accepted, to allow for a clock that’s off. A code is good once.
Recovery codes
RecoveryCodeService.generate makes ten codes that stand in for a lost phone.
Show them to the user once. The server keeps a hash of each and can’t show
them again. Each code works once, in place of a one-time code at sign-in, and
generating again replaces whatever was left.
The encryption key
JdbcTotpRepository seals each authenticator secret with AES-GCM before it
stores it, under a key the database doesn’t hold:
cn1.security.mfa.encryptionKey=replace-with-the-output-of-openssl-rand-base64-32
The key is 32 bytes in base64:
openssl rand -base64 32
Outside a development profile the server doesn’t start without the key. Losing the key loses every enrollment, and so does changing it.
Passkeys
webAuthn lets a signed-in user register a passkey and, from then on, sign in
with it:
http.formLogin(Customizer.withDefaults())
.webAuthn(passkeys -> passkeys
.rpId("example.com")
.rpName("Example")
.allowedOrigins("https://example.com",
"android:apk-key-hash:Zm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9"));
The rpId is the domain the passkeys belong to. The allowed origins
are where a ceremony may run, and each is compared whole with what the client
reports. A web origin is a scheme, a host and a port when it isn’t the default,
with no path and no trailing slash.
An Android application reports android:apk-key-hash: followed by the
base64url SHA-256 of its signing certificate. List that origin beside the web
one for the application to use the same passkeys. This command prints the hash
for a key in a keystore:
keytool -exportcert -alias upload -keystore release.keystore \
| openssl sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '='
The two ceremonies
Registration makes a passkey for a user who’s already signed in. Sign-in
proves that the caller holds one. Each ceremony is two requests: the client
asks for options, which carry a challenge, and then sends back what the
authenticator answered. The endpoints take and answer the JSON forms the
WebAuthn specification defines, so a browser’s parseCreationOptionsFromJSON
and toJSON() and the Codename One client’s WebAuthnClient work with them
as they are. The chain serves no page.
| Request | Who | What it does |
|---|---|---|
| Signed in | Answers the options to make a passkey with. |
| Signed in | Takes the authenticator’s answer and stores the passkey. A refused answer gets a 400. |
| The owner | Removes a passkey. |
| Anybody | Answers the options to sign in with. |
| Anybody | Takes the authenticator’s answer and signs the user in. A refused answer gets a 401 that doesn’t say why. |
"Signed in" means in this session. Somebody a remember-me cookie brought back
is asked to sign in before they may add or remove a passkey. A ceremony’s
options wait in the session for five minutes, and a challenge is answered
once. The memory and database session stores consume those options atomically,
including across server instances sharing the database. A custom SessionStore
must implement consumeAttribute with the same guarantee; the default refuses
the ceremony instead of using a separate read and delete.
A passkey whose authenticator verified the user, with a fingerprint, a face or
a PIN, is two factors in one step. It signs the user in without mfa asking
for a code. One that didn’t verify the user is one factor, and the second
factor is asked for, the way it’s asked for after a password. Call
userVerification("required") to make every passkey sign-in the first kind.
A registration whose attestation isn’t none or self-signed packed is
refused unless you call allowUnverifiedAttestation(true).
Apps and the CSRF token
On a chain that keeps a session, the ceremony’s requests are checked for the
CSRF token like any other POST. A page sends it as it does for its other
requests. An app has no page to read it from, so leave the ceremony out of the
check:
http.csrf(csrf -> csrf.ignoringRequestMatchers(
AntPathRequestMatcher.antMatcher("POST", "/webauthn/**"),
AntPathRequestMatcher.antMatcher("POST", "/login/webauthn")));
The four POST requests lose little by it, because each one answers a random
challenge kept in the caller’s own session. The DELETE has no such
protection, which is why the example leaves it under the check.
Where passkeys are kept
Passkeys are kept in the application’s UserCredentialRepository and
PublicKeyCredentialUserEntityRepository beans:
@Bean
UserCredentialRepository passkeys(DataSource dataSource) {
return new JdbcUserCredentialRepository(dataSource);
}
@Bean
PublicKeyCredentialUserEntityRepository passkeyUsers(DataSource dataSource) {
return new JdbcPublicKeyCredentialUserEntityRepository(dataSource);
}
With neither, they’re kept in memory and are gone when the server stops, which
a server outside a development profile says once when it starts. Neither table
holds a secret: a public key verifies and can’t sign. A user who signs in with
a passkey is looked up in the application’s UserDetailsService for their
authorities.
Remembering a user
rememberMe gives a user who ticks the box on the login form a cookie that
signs them in on a later visit:
http.formLogin(Customizer.withDefaults())
.rememberMe(remember -> remember
.tokenRepository(new JdbcTokenRepository(dataSource))
.tokenValiditySeconds(30 * 24 * 3600));
The cookie holds a series and a token, and the server keeps token hashes.
Using the cookie replaces the token. For ten seconds after that replacement,
parallel requests may use either the current token or its immediate predecessor
without replacing it again. The database keeps the preceding hash, so this grace
also works across servers. An older or unrelated token is treated as a possible
copy, and every remembered sign-in of that user is deleted. The cookie lasts two
weeks unless you set tokenValiditySeconds.
A remembered user is authenticated, but not fully. fullyAuthenticated() in
the rules and isFullyAuthenticated() in a @PreAuthorize refuse them until
they sign in again. Signing out deletes the cookie and forgets the user in
every browser.
On a chain with mfa, the cookie of a user who has a second factor works only
if the sign-in that issued it passed that factor. The server records which
kind each cookie is.
Without a tokenRepository or a PersistentTokenRepository bean, the tokens
are kept in memory. They’re then forgotten when the server restarts, and
they’re no use to a deployment of several instances.
Keeping it in the database
Each store in this chapter has an implementation that keeps its rows in the
server’s own database. The tables come from a migration set that ships with
the security layer. Ask for it in application.properties:
cn1.security.schema.enabled=true
The build reads the key and registers the set in the server it generates, so
this is a build-time setting. It has to be in the module’s
application.properties. A server that finds it true only at run time, in
the environment, a system property or a properties file beside it, doesn’t
start, and the error says where the key belongs. Set to false at run time in
a server built with it, the set isn’t applied, and the tables are whatever the
database already has. The other build-time keys follow the same two rules; see
Build-time settings.
The cn1:migrate goals of a module that asks for the tables apply them too,
before the module’s own scripts, and cn1:migrate-info lists both histories.
A module with no scripts of its own gets the goals as well, for the security
tables alone.
Each version is recorded in cn1_security_schema_history under a
script name of its own, such as
com.codename1.backend.security.SecuritySchema.V4__second_factors, and
cn1:migrate prints that name when it applies the version. A history written
before the versions had names holds one class name in every row. That history
still validates and migrates, because the script name isn’t compared with
anything, and its rows are left as they were written.
The goals run for a module that has scripts of its own.
A test, or a server you assemble by hand, registers the set before the server starts:
Migrations.register(SecuritySchema.migrations());
The tables are migrations, and Schema migrations describes how
those run. The set is called security. It keeps its history in
cn1_security_schema_history, apart from your own, and runs before the
application’s migrations, so your scripts may refer to its tables. The tables
are the same on SQLite, PostgreSQL, and MySQL.
| Version | Tables | Used by |
|---|---|---|
1 |
|
|
2 |
|
|
3 |
|
|
4 |
|
|
5 |
|
|
6 |
|
|
7 |
|
|
8 |
|
|
9 |
|
|
10 |
| Cleanup retains active windows even when limiters use different periods. |
11 |
| A brief grace period for parallel uses of a remembered sign-in after token rotation. |
The key creates the tables. It doesn’t choose the stores. Each store is still a bean you declare, or an object you hand to a chain, as the examples in this chapter do. A feature whose store you don’t declare keeps its state in memory where it has an in-memory default, and the sections above say which do.
Secrets aren’t stored as they are. Passwords are stored as their encoder wrote
them. Recovery codes use salted PBKDF2 password hashes; checking one still ends
with an atomic removal, so concurrent requests can’t use it twice. Recovery
codes from earlier builds that stored plain SHA-256 digests must be regenerated.
Verification allows at most four concurrent requests per process and one per
username across service instances. Excess attempts fail immediately without
queuing hash work or consuming a code; a completed or failed check releases
its slot.
API keys, remembered sign-in tokens, authorization codes, refresh tokens and
device codes are stored as SHA-256 hashes. Authenticator secrets are sealed under
cn1.security.mfa.encryptionKey.
Anything that must happen once is one conditional statement and the count of rows it changed. Two instances that present the same code at the same moment can’t both be told yes. That covers a revoked key, a replaced token, a used recovery code and a rate limit’s last permit.
JdbcRateLimiter.deleteExpired, JdbcTokenRepository.deleteExpired and
OAuth2AuthorizationService.purgeExpired remove rows nobody will present
again. The first two are yours to call from a scheduled job.
Testing
The test library that Backend testing describes has Spring Security’s test support under the same names:
import com.codename1.backend.annotations.Autowired;
import com.codename1.backend.test.BackendTest;
import com.codename1.backend.test.MockMvc;
import com.codename1.backend.test.WithMockUser;
import org.junit.jupiter.api.Test;
import static com.codename1.backend.test.MockMvcRequestBuilders.get;
import static com.codename1.backend.test.MockMvcRequestBuilders.post;
import static com.codename1.backend.test.MockMvcResultMatchers.status;
import static com.codename1.backend.test.SecurityMockMvcRequestPostProcessors.apiKey;
import static com.codename1.backend.test.SecurityMockMvcRequestPostProcessors.csrf;
import static com.codename1.backend.test.SecurityMockMvcRequestPostProcessors.httpBasic;
import static com.codename1.backend.test.SecurityMockMvcRequestPostProcessors.jwt;
import static com.codename1.backend.test.SecurityMockMvcRequestPostProcessors.user;
@BackendTest
class SecuredApiTest {
@Autowired
private MockMvc mvc;
@Test
@WithMockUser(username = "ada", roles = "ADMIN")
void anAdminSeesTheReport() throws Exception {
mvc.perform(get("/admin/report")).andExpect(status().isOk());
}
@Test
void aStrangerIsSentToSignIn() throws Exception {
mvc.perform(get("/admin/report")).andExpect(status().isFound());
}
@Test
void aTokenNeedsTheScope() throws Exception {
mvc.perform(get("/api/orders/7").with(jwt().subject("svc").scopes("orders:read")))
.andExpect(status().isOk());
mvc.perform(get("/api/orders/7").with(jwt().scopes("profile")))
.andExpect(status().isForbidden());
mvc.perform(get("/api/orders/7").with(apiKey("billing").scopes("orders:read")))
.andExpect(status().isOk());
}
@Test
void aPostNeedsTheCsrfToken() throws Exception {
mvc.perform(post("/notes").with(user("ada").roles("EDITOR")).content("{}"))
.andExpect(status().isForbidden());
mvc.perform(post("/notes").with(user("ada").roles("EDITOR")).with(csrf())
.content("{}"))
.andExpect(status().is2xxSuccessful());
}
@Test
void realCredentialsAreChecked() throws Exception {
mvc.perform(get("/internal/health").with(httpBasic("svc", "wrong")))
.andExpect(status().isUnauthorized());
}
}
@WithMockUser runs a test, or every test of a class, as a signed-in user who
exists nowhere but in the test. No user store is asked and no password is
checked. Every request the test sends through MockMvc comes from that user,
and SecurityContextHolder on the test’s thread names them too.
@WithAnonymousUser runs one test of such a class as nobody.
The request post-processors say something about one request:
| Post-processor | What the request carries |
|---|---|
| A signed-in user, with the role |
| A bearer token that was accepted. No token is sent and none is verified. A
request it’s refused for gets the 403 a real token gets, |
| An API key of that owner that was accepted. No repository is asked. A refusal is the one a real key gets. |
| Real credentials in an |
| A valid CSRF token in the |
| Nobody, and an authentication you built yourself. |
TestSecurityContextHolder changes who a test runs as part-way through.
Requests sent over a socket with TestRestTemplate aren’t affected by any of
this. They’re served on the server’s threads and authenticate as a real client
does.
A test’s @PreAuthorize expressions call the beans of the test’s application,
so a bean replaced with @MockitoBean is the one an expression reaches.
A server links only the security it declares
A packaged server is one native binary, and the build leaves out every class
that nothing refers to. The security layer is written so that each feature is
reached only from the HttpSecurity method that turns it on. A server
therefore carries the code of what its chains ask for and nothing else. A
server that only verifies tokens has no login page, no password hashing and no
user store in it. One that only has a login form has no JWT or key-handling
code.
That keeps the binary small, and it has consequences you can see:
No chain, no security. A server without a
SecurityFilterChainbean links none of the layer. Method security and theAuthenticationhandler parameter are build errors there.Sign-out comes with a session.
formLogin,oauth2Login,rememberMe,mfaandwebAuthnbringPOST /logoutand the memory of where a request was going. A chain without one of them lacks both until it callslogoutorrequestCache.A stateless chain has no CSRF filter unless it calls
csrf.Database stores are declared. Setting
cn1.security.schema.enabledcreates the tables, but nothing picks aJdbcstore for you. A server that declares none of them carries no SQL for them.Client secrets need an encoder you declare. The authorization server compares secrets through the
PasswordEncoderbean orclientSecretEncoder. It doesn’t bring the password encoders with it.Sign in with Apple is declared in code. A registration read from the configuration never signs a client secret, so a server that doesn’t declare
AppleClientSecretcarries nothing that signs one. Declaring Apple in the configuration stops the start.The configured user belongs to the password mechanisms. The
cn1.security.user.*keys are read byformLogin,httpBasicandrememberMeonly.
Configuration keys
Every key here is read when the server starts, from wherever the server’s
configuration comes from, except cn1.security.schema.enabled. That one is
read by the build, because it decides what’s linked into the server.
| Key | What it sets |
|---|---|
| The one user of a server with no user store. The user exists when the
password is set. The name is |
| The most passwords checked at one time. No bound unless set. |
|
|
| The issuer whose tokens a resource server accepts. |
| Where the keys are, for an issuer that publishes no metadata. |
| A PEM file holding the one public key. |
| The signature algorithms accepted, separated by commas. |
| What this server is called in a token’s |
| One provider to sign in through. See Signing in through another provider. |
| The endpoints of a provider that isn’t built in. |
| The key that signs the pending sign-in of a provider that answers with a posted form. A key made at start-up unless set. |
| The address clients reach the authorization server at. Required outside a development profile. |
| The |
| The PEM files of the signing keys, the signing one first. Required outside a development profile. |
| How many device codes one user may try, and in how many seconds. 10 and 300 unless set. |
| How many of a user’s wrong one-time codes one client address may use up, and
how many wrong recovery codes it may try for that user. Half of
|
| How many wrong one-time codes a user has from everywhere, and in how many seconds. 5 and 300 unless set. |
| The key that seals authenticator secrets in the database, 32 bytes in base64. Required outside a development profile. |
|
|
| The proxies, as addresses and CIDR ranges separated by commas. No trusted peers unless explicitly listed. |
How this differs from Spring Security
Packages. The classes live under
com.codename1.backend.security, and the annotations incom.codename1.backend.annotations.No security without a chain. Spring Boot secures every route once the starter is on the classpath, and generates a password. Here a server with no
SecurityFilterChainbean has no security layer.Sign-out isn’t on every chain. A chain without a way of signing in to a session has no
POST /logoutuntil it callslogout.A stateless chain skips CSRF. It has the filter only when it calls
csrf.Method security is compiled. The build turns each expression into Java and weaves the check into the method. It applies to self-calls, private methods and objects made with
new. Only the subset in Method security is accepted.New passwords use
{pbkdf2-sha256}, not{bcrypt}.bcryptis written in Java here and costs more of a thread, so it’s kept for verifying.{noop}works on a development profile only.The authorization server has no consent page. A client is authorized for the scopes it was registered with, and
requireAuthorizationConsentacceptsfalseonly.OAuth2AuthorizationServiceisn’t Spring’s interface. It has nosaveandfindByTokenpair. A store uses a secret up in one atomic call.The security context stays on the request’s thread. It isn’t propagated to an
@Asyncmethod or a@Scheduledjob.Passkey endpoints are JSON only. The chain serves no page for them.
What isn’t supported
In expressions:
hasPermissionand ACLs,returnObject,T(…), property chains such as#order.owner.id, and@PostAuthorize,@PreFilterand@PostFilter. Put the decision in a bean method and call it.Method security on an interface method or an abstract method, and two security annotations on one element.
Replaying a request after sign-in. Only the address of a
GETis remembered.Concurrent-session control.
sessionManagementsets the creation policy and the fixation strategy, and nothing else.SAML and LDAP. The ways of signing in are the ones in this chapter. Anything else is a filter or an
AuthenticationProviderof your own.Other password schemes. The delegating encoder knows
{pbkdf2-sha256},{bcrypt}, the two Spring PBKDF2 schemes and{noop}. A password stored under another id, such as{argon2}or{scrypt}, is refused unless you register an encoder for it.Opaque access tokens and token introspection. A resource server verifies JWTs, and the authorization server issues them.
In the authorization server: dynamic client registration,
private_key_jwtand mutual TLS client authentication, pushed authorization requests, DPoP, encrypted tokens, the implicit and password grants, and back-channel or RP-initiated logout.Passkey attestation beyond
noneand self-signedpacked. Other formats are refused, or accepted unverified when you allow it.Shared in-memory state.
InMemoryRateLimiterand the in-memory stores are per process. Use the database stores for a server that runs as several instances, and declare aJdbcRateLimiterbean so the second factor’s attempts and the device page’s tries are counted once for every instance.