spring-security
Skill18 starsChanged 4 months agoMITView on GitHub
Authentication and authorization for Spring Boot Kotlin backends - stateless JWT access tokens via Spring Security's OAuth2 resource server (HMAC-signed, issued by the app itself), opaque rotating refresh tokens, the SecurityFilterChain in :app, public vs authenticated routes, reading the current user (requestUserId), password hashing, one-time tokens for email verification and password reset, resource ownership checks, custom authentication filters (API keys), and WebSocket handshake auth. Use this skill whenever touching login, registration, tokens, the security config, route protection, the current user, passwords, roles, API keys, CORS, or anything returning 401 or 403. Trigger on phrases like "security", "auth", "login", "register", "JWT", "access token", "refresh token", "SecurityFilterChain", "permitAll", "authenticated", "current user", "requestUserId", "password hash", "BCrypt", "API key", "filter", "OncePerRequestFilter", "401", "403", "role", "permission", "CORS", or "WebSocket auth".
- Reads credentials
4 other projects use a version of this.
Adopted by
Most recently taken up by
- github/awesome-copilottheir own version, first seen Nov 8, 2026
- ashrafiucse/security-audit-skillstheir own version, first seen Oct 13, 2026
- senoritadeveloper01/claude-skillstheir own version, first seen Mar 24, 2026
- jander99/skillstheir own version, first seen Mar 15, 2026
Other versions4 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 4
- github/awesome-copilot★ 41ksame name
- senoritadeveloper01/claude-skills★ 4same name
- ashrafiucse/security-audit-skills★ 2same name
- jander99/skills★ 1same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository67 other files it gives its agents.
- agent-onboardingSkill
- android-backgroundSkill
- android-cliSkill
- android-compose-architectureSkill
- android-compose-componentsSkill
- android-data-layerSkill
- android-di-koinSkill
- android-error-handlingSkill
- android-integration-testingSkill
- android-jacoco-setupSkill
- android-jvm-test-coverageSkill
- android-module-structureSkill
- android-navigationSkill
- android-presentation-mviSkill
- android-preview-figma-verifySkill
- android-ui-screenshot-verifySkill
- android-ui-testingSkill
- android-ui-verifySkill
- android-unit-testingSkill
- android-version-catalogSkill
- architecture-decision-recordSkill
- careful-planningSkill
- compose-adaptive-layoutsSkill
- figma-to-android-vectorSkill
- finalizeSkill
- git-commitSkill
- handoffSkill
- kmp-compose-architectureSkill
- kmp-compose-componentsSkill
- kmp-data-layerSkill
- kmp-di-koinSkill
- kmp-error-handlingSkill
- kmp-integration-testingSkill
- kmp-migration-analyzerSkill
- kmp-module-structureSkill
- kmp-navigationSkill
- kmp-presentation-mviSkill
- kmp-ui-testingSkill
- kmp-unit-testingSkill
- kotlin-coroutinesSkill
- kotlin-flowsSkill
- kotlin-mutation-testingSkill
- openspec-artifact-review-lightSkill
- openspec-artifact-reviewSkill
- project-breakdownSkill
- react-data-layerSkill
- react-error-handlingSkill
- react-integration-testingSkill
- react-presentation-mviSkill
- react-project-structureSkill
- react-routingSkill
- react-screen-architectureSkill
- react-securitySkill
- react-server-stateSkill
- react-ui-componentsSkill
- react-unit-testingSkill
- speckit-parallelize-agent-teamsSkill
- speckit-parallelize-subagentsSkill
- spring-api-layerSkill
- spring-di-configurationSkill
- The first 60 shown.
The file
We split this skill into the rules it gives an agent
10 rules. 0 are common, 4 are distinctive, and 6 are very specific to this project.
0
Common
Most of the 3 edited versions we read keep these.
4
Distinctive
This author's own choices. Worth a look.
6
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 11
Stateless: No HTTP session, no CSRF tokens.
- Very specific: line 141
Rotate on every refresh: Delete the presented token and issue a new pair in the same transaction.
- Distinctive: line 142
Delete all of a user's refresh tokens on password change and password reset.
- Very specific: line 205
Use the Kotlin DSL (import org.springframework.security.config.annotation.web.invoke).
- Very specific: line 226
Only controllers and other api code read requestUserId.
- Distinctive: line 227
Never trust a user ID from the request body or path for "who is acting".
- Very specific: line 239
Prefer 404 over 403 when a resource belongs to someone else, so IDs can't be probed (see spring-api-layer).
- Distinctive: line 246
When a client can't use JWTs (a partner integration, an internal service):
- Very specific: line 283
Every protected endpoint has a test that it answers 401 without a token.
- Very specific: line 284
Every ownership rule has a test where another user gets 404/403.
“Common” is measured: kept by at least 6 in 10 of the 3 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Spring Security
Principles
- Stateless. No HTTP session, no CSRF tokens. Every request carries a bearer access token.
- Use Spring Security's JWT support, don't hand-roll it. Validation goes through the OAuth2 resource server (
JwtDecoder), issuing throughJwtEncoder. No custom filter that parses JWTs, and no catch-alltry/catcharound token parsing. - Two levels of authorization. The filter chain decides who may call a route at all (public vs authenticated, roles). Services decide whether this user may touch this resource (ownership, membership) and throw domain exceptions.
- Secrets never leave the server unhashed at rest. Passwords, refresh tokens, and one-time tokens are stored as hashes.
Dependencies
// :app and :common
implementation(libs.spring.boot.starter.security)
implementation(libs.spring.boot.starter.security.oauth2.resource.server)
// tests
testImplementation(libs.spring.boot.starter.security.test)Where Things Live
| Piece | Location |
SecurityFilterChain, route rules | :app (api/security/SecurityConfig) |
JwtDecoder bean, JwtProperties, requestUserId | :common (infra/security, api) |
JwtEncoder, token issuing, login/refresh/logout | :user (infra/security, service) |
| Password hashing | :user (domain interface PasswordHasher, infra implementation) |
| Ownership checks | each module's services |
The filter chain sits in :app because it has to know every module's public routes.
JWT Configuration
@ConfigurationProperties(prefix = "chirp.security.jwt")
@Validated
data class JwtProperties(
@field:NotBlank
val secretBase64: String,
val accessTokenTtl: Duration = Duration.ofMinutes(15),
val refreshTokenTtl: Duration = Duration.ofDays(30),
) {
fun secretKey(): SecretKey = SecretKeySpec(Base64.getDecoder().decode(secretBase64), "HmacSHA256")
}chirp:
security:
jwt:
secret-base64: ${JWT_SECRET_BASE64}
access-token-ttl: 15mThe secret is at least 256 random bits (openssl rand -base64 32), only ever from the environment.
Decoder (:common)
@Configuration(proxyBeanMethods = false)
class JwtDecoderConfig {
@Bean
fun jwtDecoder(properties: JwtProperties): JwtDecoder {
return NimbusJwtDecoder
.withSecretKey(properties.secretKey())
.macAlgorithm(MacAlgorithm.HS256)
.build()
.apply {
setJwtValidator(
DelegatingOAuth2TokenValidator(
JwtValidators.createDefault(),
JwtClaimValidator<String>(TOKEN_TYPE_CLAIM) { it == ACCESS_TOKEN_TYPE },
)
)
}
}
}The type claim check stops anything other than an access token from authenticating a request.
Encoder and issuing (:user)
@Component
class JwtAccessTokenIssuer(
private val jwtEncoder: JwtEncoder,
private val properties: JwtProperties,
private val clock: Clock,
) : AccessTokenIssuer {
override fun issue(userId: UserId): AccessToken {
val now = Instant.now(clock)
val expiresAt = now.plus(properties.accessTokenTtl)
val claims = JwtClaimsSet.builder()
.subject(userId.toString())
.claim(TOKEN_TYPE_CLAIM, ACCESS_TOKEN_TYPE)
.issuedAt(now)
.expiresAt(expiresAt)
.build()
val header = JwsHeader.with(MacAlgorithm.HS256).build()
val token = jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).tokenValue
return AccessToken(value = token, expiresAt = expiresAt)
}
}@Bean
fun jwtEncoder(properties: JwtProperties): JwtEncoder =
NimbusJwtEncoder(ImmutableSecret<SecurityContext>(properties.secretKey()))- Access tokens are short-lived (≈15 min) and carry only
sub(the user ID),type,iat,exp, plus roles if the app has them. No email, no username, no personal data. AccessTokenIssueris a domain interface, soAuthServicestays unit-testable with a fake.
Refresh Tokens
Refresh tokens are opaque random strings, not JWTs:
- Generate 32 bytes from
SecureRandom, URL-safe Base64 without padding. - Store only the SHA-256 hash, with user ID and expiry. The raw value goes to the client once.
- Rotate on every refresh. Delete the presented token and issue a new pair in the same transaction. An unknown or expired token is
401 INVALID_TOKEN. - Delete all of a user's refresh tokens on password change and password reset.
- Logout deletes the presented token.
- A scheduled job deletes expired rows.
One-time tokens for email verification and password reset follow the same rules: random, stored hashed, single use (usedAt), short expiry, all previous tokens of the user invalidated when a new one is issued.
Passwords
// domain
interface PasswordHasher {
fun hash(rawPassword: String): String
fun matches(rawPassword: String, hash: String): Boolean
}
// infra
@Component
class BCryptPasswordHasher : PasswordHasher {
private val encoder = BCryptPasswordEncoder()
override fun hash(rawPassword: String): String = requireNotNull(encoder.encode(rawPassword))
override fun matches(rawPassword: String, hash: String): Boolean = encoder.matches(rawPassword, hash)
}- Login failures return the same
401 INVALID_CREDENTIALSwhether the email is unknown or the password is wrong. Never reveal which. - "Forgot password" always answers success, whether or not the email exists.
- Public auth endpoints (register, login, refresh, forgot password, resend verification) are rate limited per IP and, where an email is involved, per email.
Filter Chain (:app)
@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
class SecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
csrf { disable() }
sessionManagement { sessionCreationPolicy = SessionCreationPolicy.STATELESS }
authorizeHttpRequests {
authorize("/api/auth/change-password", authenticated)
authorize("/api/auth/**", permitAll)
authorize("/actuator/health", permitAll)
authorize("/error", permitAll)
authorize(anyRequest, authenticated)
}
oauth2ResourceServer {
jwt { }
}
}
return http.build()
}
}- Use the Kotlin DSL (
import org.springframework.security.config.annotation.web.invoke). - Deny by default.
anyRequestisauthenticated. Every public route is listed explicitly. - Rules are matched in order, so the more specific rule comes first (
/api/auth/change-passwordbefore/api/auth/**). - Missing or invalid tokens get
401with aWWW-Authenticateheader from the resource server's entry point. Don't replace it with a custom entry point that writes JSON by hand. - CSRF is disabled only because the API is stateless and uses bearer tokens. If cookies ever carry auth, CSRF protection must come back.
- CORS is configured only when browser clients call the API, with an explicit list of origins from configuration. Never
*with credentials.
The Current User
// common.api
val requestUserId: UserId
get() {
val authentication = SecurityContextHolder.getContext().authentication as? JwtAuthenticationToken
?: throw UnauthorizedException()
return UUID.fromString(authentication.token.subject)
}- Only controllers and other
apicode readrequestUserId. Services receive the user ID as a parameter, so they work the same from listeners and jobs, and tests don't need a security context. - Never trust a user ID from the request body or path for "who is acting".
Resource Authorization in Services
val chat = chatRepository.findByIdForParticipant(chatId, requestUserId)
?: throw ChatNotFoundException(chatId)- Ownership and membership checks are part of the query or an explicit check in the service, before any change.
- Prefer
404over403when a resource belongs to someone else, so IDs can't be probed (see spring-api-layer). - Roles, if the app has them, go into the token as a claim, are mapped to authorities with a
JwtAuthenticationConverter, and are checked in the filter chain (hasRole("ADMIN")). Don't scatter@PreAuthorizefor ownership logic.
Custom Authentication Filters (API keys, service tokens)
When a client can't use JWTs (a partner integration, an internal service):
@Component
class ApiKeyAuthenticationFilter(
private val apiKeyService: ApiKeyService,
) : OncePerRequestFilter() {
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
val apiKey = request.getHeader(API_KEY_HEADER)
if (apiKey != null) {
val client = apiKeyService.findActiveClient(apiKey)
if (client != null) {
SecurityContextHolder.getContext().authentication = ApiKeyAuthentication(client)
}
}
chain.doFilter(request, response)
}
}- A filter only establishes identity. It never writes an error response itself. Whether a route needs authentication is decided by the filter chain rules, and the entry point writes the
401. - Register it in the chain with
addFilterBefore<BearerTokenAuthenticationFilter>(apiKeyAuthenticationFilter). - A
@Componentfilter is also registered as a plain servlet filter by Spring Boot, so it would run twice. Disable that with aFilterRegistrationBeanwithisEnabled = false, or don't make the filter a@Componentand create it inSecurityConfig. - API keys are stored hashed and compared via the hash, like refresh tokens.
WebSocket Handshake
Browsers can't set headers on WebSocket connections, and mobile clients can. Pick one transport for the token and validate it during the handshake with the same JwtDecoder, in a HandshakeInterceptor. Reject the handshake when the token is missing or invalid. Don't accept the connection first and close it afterwards.
Testing
- E2E tests get real tokens through the API (register, verify, log in) or from a small test helper that uses the real
JwtEncoder. They don't mock the security context (see spring-integration-testing). - Every protected endpoint has a test that it answers
401without a token. - Every ownership rule has a test where another user gets
404/403.
Naming Conventions
| Thing | Convention | Example |
| Filter chain config | SecurityConfig | |
| Token issuer interface / impl | AccessTokenIssuer / JwtAccessTokenIssuer | |
| Password hashing interface / impl | PasswordHasher / BCryptPasswordHasher | |
| Custom filter | <Scheme>AuthenticationFilter | ApiKeyAuthenticationFilter |
| Claim names | constants in common | TOKEN_TYPE_CLAIM, ACCESS_TOKEN_TYPE |
Checklist: Adding or Changing a Protected Route
- Route is covered by
anyRequest().authenticated(), or explicitly listed as public with a reason - Controller reads the acting user from
requestUserId - Service checks ownership/membership before reading or changing the resource
- Nothing sensitive (tokens, passwords, emails) is logged
- Tests for
401without a token and for access by another user
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
Reviews can't be read right now.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

